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:
co-authored by
Claude Opus 5
parent
23087a12fb
commit
2717462c3a
@@ -88,7 +88,7 @@ weg.
|
|||||||
| Feld | wofür |
|
| Feld | wofür |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Datum** | wann gefunden. Vorbelegt mit heute; entscheidet später über Archiv und Löschung |
|
| **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 |
|
| **Anbieter** | Freitext: Vermittler oder Portal, z. B. Hays. Vorschläge aus den bisherigen Einträgen |
|
||||||
| **Kanal** | Auswahl: auf welchem Weg es kam |
|
| **Kanal** | Auswahl: auf welchem Weg es kam |
|
||||||
| **Projekttitel** | das einzige Pflichtfeld |
|
| **Projekttitel** | das einzige Pflichtfeld |
|
||||||
@@ -99,6 +99,12 @@ weg.
|
|||||||
*Einstellungen → Akquise* und lassen sich dort von jedem Benutzer pflegen. Ist
|
*Einstellungen → Akquise* und lassen sich dort von jedem Benutzer pflegen. Ist
|
||||||
eine Liste noch leer, steht unter dem Feld ein Verweis dorthin.
|
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
|
**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
|
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.
|
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 |
|
| Spalte | Bedeutung |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Datum** | Funddatum; der Mauszeiger verrät das Alter in Tagen |
|
| **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 |
|
| **Projekttitel** | mit Link anklickbar (neuer Tab); darunter klein Endkunde, Tagessatz bzw. Festpreis und Personentage |
|
||||||
| **Berater** | für wen |
|
| **Berater** | für wen |
|
||||||
| **Opportunity** | ein Klick macht daraus eine Opportunity in der Phase *Neu* |
|
| **Opportunity** | ein Klick macht daraus eine Opportunity in der Phase *Neu* |
|
||||||
|
|||||||
@@ -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/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/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/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/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
|
||||||
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart |
|
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart |
|
||||||
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
|
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DictateTextarea } from '../ui/DictationField';
|
|||||||
import { OptionSelect, fieldClass } from './acquisitionUi';
|
import { OptionSelect, fieldClass } from './acquisitionUi';
|
||||||
import { labelClass } from '../leads/leadUi';
|
import { labelClass } from '../leads/leadUi';
|
||||||
import { useToast } from '../Toast';
|
import { useToast } from '../Toast';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
import { useNavigation } from '../../lib/navigation';
|
import { useNavigation } from '../../lib/navigation';
|
||||||
import { acquisitionsApi, type AcquisitionInput } from '../../lib/api';
|
import { acquisitionsApi, type AcquisitionInput } from '../../lib/api';
|
||||||
import {
|
import {
|
||||||
@@ -58,10 +59,16 @@ const today = () => new Date().toISOString().slice(0, 10);
|
|||||||
const numberToInput = (value: number | null) =>
|
const numberToInput = (value: number | null) =>
|
||||||
value === null ? '' : String(value).replace('.', ',');
|
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 {
|
return {
|
||||||
found_at: entry?.found_at?.slice(0, 10) ?? today(),
|
found_at: entry?.found_at?.slice(0, 10) ?? today(),
|
||||||
sender: entry?.sender ?? '',
|
sender: entry?.sender ?? ownName,
|
||||||
provider: entry?.provider ?? '',
|
provider: entry?.provider ?? '',
|
||||||
channel: entry?.channel ?? '',
|
channel: entry?.channel ?? '',
|
||||||
title: entry?.title ?? '',
|
title: entry?.title ?? '',
|
||||||
@@ -87,9 +94,25 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
|||||||
onCancelEdit,
|
onCancelEdit,
|
||||||
}) => {
|
}) => {
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
const openSettings = () => goTo('Einstellungen', { tab: 'akquise' });
|
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 [showMore, setShowMore] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const titleRef = useRef<HTMLInputElement>(null);
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -104,7 +127,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
|||||||
* Liste, das Formular oben.
|
* Liste, das Formular oben.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setForm(toFormState(editing));
|
setForm(toFormState(editing, ownName));
|
||||||
if (editing) {
|
if (editing) {
|
||||||
const hasExtras = Boolean(
|
const hasExtras = Boolean(
|
||||||
editing.company ||
|
editing.company ||
|
||||||
@@ -121,7 +144,10 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
|||||||
rootRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
rootRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
titleRef.current?.focus();
|
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.
|
// Volumen mitrechnen wie in der Opportunity: Festpreis schlaegt Tagessatz.
|
||||||
const volume = useMemo(() => {
|
const volume = useMemo(() => {
|
||||||
@@ -247,6 +273,8 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
|||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Vorbelegt mit dem angemeldeten Benutzer; fuer jemand anderen wird er
|
||||||
|
in der Liste gewechselt. */}
|
||||||
<OptionSelect
|
<OptionSelect
|
||||||
id="acq-sender"
|
id="acq-sender"
|
||||||
label={ACQUISITION_FIELD_LABELS.sender}
|
label={ACQUISITION_FIELD_LABELS.sender}
|
||||||
@@ -254,6 +282,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
|
|||||||
options={options.sender}
|
options={options.sender}
|
||||||
onChange={(value) => set('sender', value)}
|
onChange={(value) => set('sender', value)}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
|
alwaysOffer={ownName}
|
||||||
/>
|
/>
|
||||||
{/* Anbieter bleibt Freitext: neue Vermittler tauchen zu oft auf, als dass
|
{/* Anbieter bleibt Freitext: neue Vermittler tauchen zu oft auf, als dass
|
||||||
das Eintragen an einer gepflegten Liste haengen sollte. Die
|
das Eintragen an einer gepflegten Liste haengen sollte. Die
|
||||||
|
|||||||
@@ -22,10 +22,16 @@ interface OptionSelectProps {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
/** Fuehrt zu Einstellungen -> Akquise, wo die Liste gepflegt wird. */
|
/** Fuehrt zu Einstellungen -> Akquise, wo die Liste gepflegt wird. */
|
||||||
onOpenSettings: () => void;
|
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
|
* 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
|
* angeboten und als solcher gekennzeichnet. Ohne das wuerde das Bearbeiten eines
|
||||||
@@ -38,8 +44,14 @@ export const OptionSelect: React.FC<OptionSelectProps> = ({
|
|||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
onOpenSettings,
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -53,6 +65,7 @@ export const OptionSelect: React.FC<OptionSelectProps> = ({
|
|||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
|
{ownEntry && <option value={ownEntry}>{ownEntry}</option>}
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<option key={option.id} value={option.value}>
|
<option key={option.id} value={option.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>}
|
{isUnlisted && <option value={value}>{value} (nicht in der Liste)</option>}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{options.length === 0 && (
|
{options.length === 0 && !ownEntry && (
|
||||||
<p className="mt-1.5 text-[11px] text-ink-400">
|
<p className="mt-1.5 text-[11px] text-ink-400">
|
||||||
Liste ist leer -{' '}
|
Liste ist leer -{' '}
|
||||||
<button type="button" onClick={onOpenSettings} className="font-bold underline">
|
<button type="button" onClick={onOpenSettings} className="font-bold underline">
|
||||||
|
|||||||
+1
-1
@@ -271,7 +271,7 @@ export const ACQUISITION_FIELD_LABELS: Record<AcquisitionOptionField, string> =
|
|||||||
|
|
||||||
/** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */
|
/** Was in der jeweiligen Liste steht - als Hilfe in den Einstellungen. */
|
||||||
export const ACQUISITION_FIELD_HINTS: Record<AcquisitionOptionField, string> = {
|
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',
|
channel: 'Auf welchem Weg das Projekt kam',
|
||||||
consultant: 'Eigene Berater, für die Projekte gesucht werden',
|
consultant: 'Eigene Berater, für die Projekte gesucht werden',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user