feat(akquise): Auswahllisten statt Freitext, Felder abgesetzt

"Von", "Anbieter", "Kanal" und "Berater" sind Dropdowns. Ihre Inhalte stehen in
crm_acquisition_options und werden im neuen Reiter Einstellungen -> Akquise
gepflegt. Freitext hat aus "Hays", "hays" und "Hays AG" drei Anbieter gemacht;
der Unique-Schluessel greift ueber die Kollation auch bei abweichender
Gross-/Kleinschreibung.

Eine Tabelle mit Feldkennzeichen statt vier Tabellen: die Felder verhalten sich
gleich, also ist jede weitere Liste ein Wert im ENUM statt einer neuen Route und
einer neuen Oberflaeche.

Zwei Entscheidungen dabei:

- Pflegen darf jeder Angemeldete, nicht nur ein Administrator. Eine Liste, die
  nur ein Admin ergaenzen kann, blockiert die Eingabe genau dann, wenn ein neuer
  Anbieter auftaucht.
- Die Eintraege halten den Text, keinen Fremdschluessel. Umbenennen zieht sie
  darum ausdruecklich mit (in einer Transaktion), Loeschen nicht: eine Liste
  aufzuraeumen darf keine Daten aendern. Ein Wert, der nicht mehr in der Liste
  steht, wird im Dropdown weiter angeboten und als solcher gekennzeichnet -
  sonst wuerde das Bearbeiten eines alten Eintrags ihn stillschweigend
  ueberschreiben.

Das Backend prueft beim Speichern eines Eintrags nur noch die Laenge und nicht
mehr die Zugehoerigkeit zur Liste - aus demselben Grund.

Dazu: die Felder der Akquise-Eingabe sind abgesetzt (getoente Flaeche,
deutlicherer Rahmen, beim Hineinklicken weiss). Auf der weissen Karte gingen sie
unter, und im Formular stehen bis zu fuenfzehn davon nebeneinander.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 12:13:21 +02:00
co-authored by Claude Opus 5
parent b8a78f609c
commit 05cc261900
14 changed files with 762 additions and 137 deletions
+41 -75
View File
@@ -1,13 +1,15 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { DictateTextarea } from '../ui/DictationField';
import { inputClass, labelClass } from '../leads/leadUi';
import { OptionSelect, fieldClass } from './acquisitionUi';
import { labelClass } from '../leads/leadUi';
import { useToast } from '../Toast';
import { useNavigation } from '../../lib/navigation';
import { acquisitionsApi, type AcquisitionInput } from '../../lib/api';
import {
ACQUISITION_CHANNELS,
ACQUISITION_FIELD_LABELS,
formatEuro,
type Acquisition,
type AcquisitionChannel,
type AcquisitionOptions,
} from '../../types';
/*
@@ -23,7 +25,8 @@ import {
interface AcquisitionFormProps {
/** Gesetzt = Bearbeiten, sonst Neuanlage. */
editing: Acquisition | null;
suggestions: { providers: string[]; consultants: string[]; senders: string[] };
/** Inhalte der Dropdowns, gepflegt unter Einstellungen -> Akquise. */
options: AcquisitionOptions;
onSaved: () => void;
onCancelEdit: () => void;
}
@@ -74,42 +77,15 @@ function toFormState(entry: Acquisition | null): FormState {
};
}
/** 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,
options,
onSaved,
onCancelEdit,
}) => {
const { addToast } = useToast();
const { goTo } = useNavigation();
const openSettings = () => goTo('Einstellungen', { tab: 'akquise' });
const [form, setForm] = useState<FormState>(() => toFormState(editing));
const [showMore, setShowMore] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -172,7 +148,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
found_at: form.found_at || today(),
sender: form.sender.trim() || null,
provider: form.provider.trim() || null,
channel: (form.channel as AcquisitionChannel) || null,
channel: form.channel || null,
title: form.title.trim(),
consultant: form.consultant.trim() || null,
link: form.link.trim() || null,
@@ -265,43 +241,33 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
type="date"
value={form.found_at}
onChange={(event) => set('found_at', event.target.value)}
className={inputClass}
className={fieldClass}
/>
</div>
<SuggestInput
<OptionSelect
id="acq-sender"
label="Von"
label={ACQUISITION_FIELD_LABELS.sender}
value={form.sender}
options={suggestions.senders}
placeholder="Wer hat es geschickt?"
options={options.sender}
onChange={(value) => set('sender', value)}
onOpenSettings={openSettings}
/>
<SuggestInput
<OptionSelect
id="acq-provider"
label="Anbieter"
label={ACQUISITION_FIELD_LABELS.provider}
value={form.provider}
options={suggestions.providers}
placeholder="z. B. Hays"
options={options.provider}
onChange={(value) => set('provider', value)}
onOpenSettings={openSettings}
/>
<OptionSelect
id="acq-channel"
label={ACQUISITION_FIELD_LABELS.channel}
value={form.channel}
options={options.channel}
onChange={(value) => set('channel', value)}
onOpenSettings={openSettings}
/>
<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]">
@@ -316,16 +282,16 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
value={form.title}
onChange={(event) => set('title', event.target.value)}
placeholder="z. B. SAP FI Rollout Süddeutschland"
className={inputClass}
className={fieldClass}
/>
</div>
<SuggestInput
<OptionSelect
id="acq-consultant"
label="Berater"
label={ACQUISITION_FIELD_LABELS.consultant}
value={form.consultant}
options={suggestions.consultants}
placeholder="für wen?"
options={options.consultant}
onChange={(value) => set('consultant', value)}
onOpenSettings={openSettings}
/>
<div>
<label className={labelClass} htmlFor="acq-link">
@@ -340,7 +306,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
value={form.link}
onChange={(event) => set('link', event.target.value)}
placeholder="hays.de/projekt/1 - macht den Titel in der Liste klickbar"
className={inputClass}
className={fieldClass}
/>
</div>
</div>
@@ -376,7 +342,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
value={form.company}
onChange={(event) => set('company', event.target.value)}
placeholder="Firma hinter dem Projekt"
className={inputClass}
className={fieldClass}
/>
</div>
<div>
@@ -388,7 +354,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
type="date"
value={form.start_date}
onChange={(event) => set('start_date', event.target.value)}
className={inputClass}
className={fieldClass}
/>
</div>
<div>
@@ -400,7 +366,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
type="date"
value={form.end_date}
onChange={(event) => set('end_date', event.target.value)}
className={inputClass}
className={fieldClass}
/>
</div>
</div>
@@ -416,7 +382,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
value={form.daily_rate}
onChange={(event) => set('daily_rate', event.target.value)}
placeholder="1150"
className={inputClass}
className={fieldClass}
/>
</div>
<div>
@@ -429,7 +395,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
value={form.duration_days}
onChange={(event) => set('duration_days', event.target.value)}
placeholder="40"
className={inputClass}
className={fieldClass}
/>
</div>
<div>
@@ -442,7 +408,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
value={form.fixed_price}
onChange={(event) => set('fixed_price', event.target.value)}
placeholder="Festpreis statt Tagessatz × Laufzeit"
className={inputClass}
className={fieldClass}
/>
</div>
</div>
@@ -472,7 +438,7 @@ export const AcquisitionForm: React.FC<AcquisitionFormProps> = ({
value={form.next_step}
onChange={(event) => set('next_step', event.target.value)}
placeholder="z. B. Profil bis Donnerstag schicken"
className={inputClass}
className={fieldClass}
/>
</div>
+2 -2
View File
@@ -9,7 +9,7 @@ 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';
import { EMPTY_ACQUISITION_OPTIONS, type Acquisition, type AcquisitionList } from '../../types';
/*
* Akquise: gefundene Projekte in einer Liste, mit der Eingabe darueber.
@@ -174,7 +174,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
) : (
<AcquisitionForm
editing={editing}
suggestions={data?.suggestions ?? { providers: [], consultants: [], senders: [] }}
options={data?.options ?? EMPTY_ACQUISITION_OPTIONS}
onSaved={() => load(true)}
onCancelEdit={() => setEditing(null)}
/>
@@ -0,0 +1,74 @@
import React from 'react';
import { labelClass } from '../leads/leadUi';
import type { AcquisitionOption } from '../../types';
/*
* Gemeinsame Bausteine der Akquise.
*
* `fieldClass` ist die abgesetzte Variante von `inputClass`: leicht getoente
* Flaeche und ein deutlicherer Rahmen. Auf der weissen Karte der Eingabe gingen
* die Felder sonst unter - im Formular stehen bis zu fuenfzehn davon
* nebeneinander, und man muss sehen, wo eines anfaengt und aufhoert. Beim
* Hineinklicken wird das Feld weiss, damit klar ist, wo man schreibt.
*/
export const fieldClass =
'w-full rounded-xl border border-ink-300 bg-ink-50 px-4 py-2.5 text-sm text-ink-900 outline-none transition hover:border-ink-400 focus:border-brand-500 focus:bg-surface focus:ring-2 focus:ring-brand-500/25';
interface OptionSelectProps {
id: string;
label: string;
value: string;
options: AcquisitionOption[];
onChange: (value: string) => void;
/** Fuehrt zu Einstellungen -> Akquise, wo die Liste gepflegt wird. */
onOpenSettings: () => void;
}
/**
* Auswahlfeld fuer "Von", "Anbieter", "Kanal" und "Berater".
*
* 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
* alten Eintrags den Wert stillschweigend auf den ersten Listeneintrag aendern.
*/
export const OptionSelect: React.FC<OptionSelectProps> = ({
id,
label,
value,
options,
onChange,
onOpenSettings,
}) => {
const isUnlisted = value !== '' && !options.some((option) => option.value === value);
return (
<div>
<label className={labelClass} htmlFor={id}>
{label}
</label>
<select
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
className={fieldClass}
>
<option value="">—</option>
{options.map((option) => (
<option key={option.id} value={option.value}>
{option.value}
</option>
))}
{isUnlisted && <option value={value}>{value} (nicht in der Liste)</option>}
</select>
{options.length === 0 && (
<p className="mt-1.5 text-[11px] text-ink-400">
Liste ist leer -{' '}
<button type="button" onClick={onOpenSettings} className="font-bold underline">
unter Einstellungen pflegen
</button>
</p>
)}
</div>
);
};
@@ -0,0 +1,310 @@
import React, { useEffect, useState } from 'react';
import { fieldClass } from '../acquisition/acquisitionUi';
import { labelClass } from '../leads/leadUi';
import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast';
import { acquisitionOptionsApi } from '../../lib/api';
import {
ACQUISITION_FIELD_HINTS,
ACQUISITION_FIELD_LABELS,
ACQUISITION_OPTION_FIELDS,
EMPTY_ACQUISITION_OPTIONS,
type AcquisitionOption,
type AcquisitionOptionField,
type AcquisitionOptions,
} from '../../types';
/*
* Inhalte der Akquise-Dropdowns pflegen: "Von", "Anbieter", "Kanal", "Berater".
*
* Vier gleich aufgebaute Listen statt vier Sonderfaelle - die Felder verhalten
* sich gleich, also sieht ihre Pflege auch gleich aus.
*
* Umbenennen zieht die vorhandenen Akquise-Eintraege mit, Loeschen nicht: eine
* Liste aufzuraeumen darf keine Daten aendern. Beide Antworten sagen, wie viele
* Eintraege betroffen sind, und das steht dann auch in der Meldung.
*/
const PlusIcon = (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 4v16m8-8H4" />
</svg>
);
const OptionRow: React.FC<{
option: AcquisitionOption;
onRenamed: (value: string) => Promise<void>;
onDelete: () => void;
}> = ({ option, onRenamed, onDelete }) => {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(option.value);
const [isSaving, setIsSaving] = useState(false);
// Wird der Wert von aussen umbenannt (oder die Liste neu geladen), soll der
// Entwurf nicht auf dem alten Stand stehen bleiben.
useEffect(() => {
setDraft(option.value);
}, [option.value]);
const save = async () => {
const value = draft.trim();
if (value === '' || value === option.value) {
setDraft(option.value);
setIsEditing(false);
return;
}
setIsSaving(true);
try {
await onRenamed(value);
setIsEditing(false);
} finally {
setIsSaving(false);
}
};
return (
<li className="flex items-center gap-2 rounded-xl border border-ink-200 bg-surface px-3 py-2">
{isEditing ? (
<input
autoFocus
value={draft}
disabled={isSaving}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
save();
}
if (event.key === 'Escape') {
setDraft(option.value);
setIsEditing(false);
}
}}
onBlur={save}
aria-label={`${option.value} umbenennen`}
className={`${fieldClass} py-1.5`}
/>
) : (
<>
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-ink-800">
{option.value}
</span>
<button
type="button"
onClick={() => setIsEditing(true)}
title="Umbenennen"
aria-label={`${option.value} umbenennen`}
className="shrink-0 rounded-lg p-1.5 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
type="button"
onClick={onDelete}
title="Aus der Liste entfernen"
aria-label={`${option.value} entfernen`}
className="shrink-0 rounded-lg p-1.5 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>
</>
)}
</li>
);
};
export const AcquisitionSettings: React.FC = () => {
const { addToast } = useToast();
const [options, setOptions] = useState<AcquisitionOptions>(EMPTY_ACQUISITION_OPTIONS);
const [isLoading, setIsLoading] = useState(true);
const [drafts, setDrafts] = useState<Record<AcquisitionOptionField, string>>({
sender: '',
provider: '',
channel: '',
consultant: '',
});
const [toDelete, setToDelete] = useState<{
field: AcquisitionOptionField;
option: AcquisitionOption;
} | null>(null);
const load = async (silent = false) => {
if (!silent) setIsLoading(true);
try {
setOptions(await acquisitionOptionsApi.list());
} catch (err) {
addToast(err instanceof Error ? err.message : 'Auswahllisten nicht abrufbar.', 'error');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
load();
// Einmal beim Oeffnen des Reiters.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const add = async (field: AcquisitionOptionField) => {
const value = drafts[field].trim();
if (!value) return;
try {
await acquisitionOptionsApi.create(field, value);
setDrafts((prev) => ({ ...prev, [field]: '' }));
addToast(`„${value}" zu ${ACQUISITION_FIELD_LABELS[field]} hinzugefügt.`, 'success');
await load(true);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Hinzufügen fehlgeschlagen.', 'error');
}
};
const rename = async (option: AcquisitionOption, value: string) => {
try {
const result = await acquisitionOptionsApi.update(option.id, value);
addToast(
result.updated > 0
? `Umbenannt - ${result.updated} Eintrag/Einträge in der Akquise mitgeändert.`
: 'Umbenannt.',
'success'
);
await load(true);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Umbenennen fehlgeschlagen.', 'error');
throw err;
}
};
const confirmDelete = async () => {
if (!toDelete) return;
try {
const result = await acquisitionOptionsApi.remove(toDelete.option.id);
addToast(
result.still_in_use > 0
? `Aus der Liste entfernt. ${result.still_in_use} Eintrag/Einträge behalten den Wert.`
: 'Aus der Liste entfernt.',
'success'
);
await load(true);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
} finally {
setToDelete(null);
}
};
if (isLoading) {
return (
<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>
);
}
return (
<div className="space-y-5">
<section className="rounded-2xl border-l-4 border-l-brand-500 border border-ink-200 bg-surface p-5">
<h3 className="text-base text-ink-900">Auswahllisten der Akquise</h3>
<p className="mt-1 text-xs text-ink-400">
Diese vier Listen füllen die Dropdowns im Formular unter <em>Akquise</em>. Feste Auswahl
statt Freitext, damit aus „Hays", „hays" und „Hays AG" nicht drei Anbieter werden.
Pflegen darf jeder angemeldete Benutzer - wer beim Eintragen auf einen neuen Anbieter
stößt, soll weiterarbeiten können.
</p>
<p className="mt-2 text-xs text-ink-400">
<strong>Umbenennen</strong> ändert die vorhandenen Akquise-Einträge mit.
<strong> Entfernen</strong> nimmt den Wert nur aus der Auswahl - Einträge, die ihn
tragen, behalten ihn und zeigen ihn als „nicht in der Liste".
</p>
</section>
<div className="grid gap-5 lg:grid-cols-2">
{ACQUISITION_OPTION_FIELDS.map((field) => (
<section key={field} className="rounded-2xl border border-ink-200 bg-surface p-5">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-base text-ink-900">{ACQUISITION_FIELD_LABELS[field]}</h3>
<span className="text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
{options[field].length} {options[field].length === 1 ? 'Eintrag' : 'Einträge'}
</span>
</div>
<p className="mt-1 text-xs text-ink-400">{ACQUISITION_FIELD_HINTS[field]}</p>
<div className="mt-4">
<label className={labelClass} htmlFor={`opt-new-${field}`}>
Hinzufügen
</label>
<div className="flex gap-2">
<input
id={`opt-new-${field}`}
value={drafts[field]}
onChange={(event) =>
setDrafts((prev) => ({ ...prev, [field]: event.target.value }))
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
add(field);
}
}}
placeholder={`Neuer Wert für ${ACQUISITION_FIELD_LABELS[field]}`}
className={fieldClass}
/>
<button
type="button"
onClick={() => add(field)}
disabled={!drafts[field].trim()}
className="flex shrink-0 items-center gap-1.5 rounded-xl bg-brand-500 px-4 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
>
{PlusIcon}
Hinzufügen
</button>
</div>
</div>
{options[field].length === 0 ? (
<p className="mt-4 rounded-xl border border-dashed border-ink-200 px-4 py-6 text-center text-xs font-semibold text-ink-400">
Noch nichts hinterlegt - das Dropdown ist dann leer.
</p>
) : (
<ul className="mt-4 space-y-2">
{options[field].map((option) => (
<OptionRow
key={option.id}
option={option}
onRenamed={(value) => rename(option, value)}
onDelete={() => setToDelete({ field, option })}
/>
))}
</ul>
)}
</section>
))}
</div>
{toDelete && (
<ConfirmDialog
title={`„${toDelete.option.value}" entfernen?`}
message={`Der Wert verschwindet aus der Auswahl ${
ACQUISITION_FIELD_LABELS[toDelete.field]
}. Vorhandene Akquise-Einträge behalten ihn - sie lassen sich weiter bearbeiten und übernehmen.`}
confirmLabel="Entfernen"
onConfirm={confirmDelete}
onCancel={() => setToDelete(null)}
/>
)}
</div>
);
};
+6 -1
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { UserManagement } from './UserManagement';
import { MailSettings } from './MailSettings';
import { AcquisitionSettings } from './AcquisitionSettings';
import { ScannerSettings } from './ScannerSettings';
import { ColorPicker } from './ColorPicker';
import { AvatarPicker } from '../ui/AvatarPicker';
@@ -14,6 +15,7 @@ import { ADMIN_SETTINGS_TABS, userColor, type SettingsTab } from '../../types';
const TABS: { id: SettingsTab; label: string }[] = [
{ id: 'users', label: 'Nutzerverwaltung' },
{ id: 'account', label: 'Mein Konto' },
{ id: 'akquise', label: 'Akquise' },
{ id: 'mail', label: 'Postfach' },
{ id: 'scanner', label: 'Scanner' },
{ id: 'whisper', label: 'Whisper' },
@@ -178,7 +180,9 @@ const AccountTab: React.FC = () => {
export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab }) => {
const { isAdmin } = useAuth();
// Nutzerverwaltung und Scanner nur fuer Admins; Konto und Whisper fuer alle.
// Nutzerverwaltung, Postfach und Scanner nur fuer Admins; Konto, Akquise und
// Whisper fuer alle - die Akquise-Auswahllisten sind Arbeitsmittel, keine
// Konfiguration, und wer eintraegt muss sie ergaenzen koennen.
const tabs = TABS.filter((item) => isAdmin || !ADMIN_SETTINGS_TABS.includes(item.id));
const defaultTab: SettingsTab = isAdmin ? 'users' : 'account';
@@ -217,6 +221,7 @@ export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab })
{tab === 'users' && isAdmin && <UserManagement />}
{tab === 'account' && <AccountTab />}
{tab === 'akquise' && <AcquisitionSettings />}
{tab === 'mail' && isAdmin && <MailSettings />}
{tab === 'scanner' && isAdmin && <ScannerSettings />}
{tab === 'whisper' && <WhisperSettings />}
+29
View File
@@ -1,6 +1,9 @@
import type {
Acquisition,
AcquisitionList,
AcquisitionOption,
AcquisitionOptionField,
AcquisitionOptions,
AppUser,
CalendarEvent,
Lead,
@@ -245,6 +248,32 @@ export const acquisitionsApi = {
}),
};
/**
* Inhalte der Dropdowns "Von", "Anbieter", "Kanal" und "Berater". Pflegen darf
* jeder Angemeldete: eine Liste, die nur ein Administrator ergaenzen kann, wuerde
* die Eingabe genau dann blockieren, wenn ein neuer Anbieter auftaucht.
*/
export const acquisitionOptionsApi = {
list: () => request<AcquisitionOptions>('/acquisitions/options'),
create: (field: AcquisitionOptionField, value: string) =>
request<AcquisitionOption & { field: AcquisitionOptionField }>('/acquisitions/options', {
method: 'POST',
body: JSON.stringify({ field, value }),
}),
/** Umbenennen zieht die vorhandenen Akquise-Eintraege mit; `updated` sagt wie viele. */
update: (id: number, value: string) =>
request<AcquisitionOption & { field: AcquisitionOptionField; updated: number }>(
`/acquisitions/options/${id}`,
{ method: 'PATCH', body: JSON.stringify({ value }) }
),
/** Entfernt nur die Auswahl; `still_in_use` zaehlt die Eintraege, die den Wert behalten. */
remove: (id: number) =>
request<{ ok: true; still_in_use: number }>(`/acquisitions/options/${id}`, { method: 'DELETE' }),
};
// ---------- Interviews (Termine) ----------
export interface EventInput {
+39 -16
View File
@@ -21,7 +21,7 @@ export type ViewState =
* `account` und `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto
* und richtet sein eigenes Diktat ein.
*/
export type SettingsTab = 'users' | 'account' | 'mail' | 'scanner' | 'whisper';
export type SettingsTab = 'users' | 'account' | 'akquise' | 'mail' | 'scanner' | 'whisper';
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'scanner'];
@@ -251,20 +251,43 @@ 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.
* Felder der Akquise, die aus einer Auswahlliste kommen. Die Werte stehen in der
* Datenbank (crm_acquisition_options) und werden unter Einstellungen -> Akquise
* gepflegt - als Freitext waeren "Hays", "hays" und "Hays AG" drei Anbieter.
*/
export const ACQUISITION_CHANNELS = [
'Mail',
'Telefon',
'Portal',
'LinkedIn',
'XING',
'Empfehlung',
'Sonstiges',
] as const;
export const ACQUISITION_OPTION_FIELDS = ['sender', 'provider', 'channel', 'consultant'] as const;
export type AcquisitionChannel = (typeof ACQUISITION_CHANNELS)[number];
export type AcquisitionOptionField = (typeof ACQUISITION_OPTION_FIELDS)[number];
/** Beschriftung der Felder - dieselbe in der Liste, im Formular und in den Einstellungen. */
export const ACQUISITION_FIELD_LABELS: Record<AcquisitionOptionField, string> = {
sender: 'Von',
provider: 'Anbieter',
channel: 'Kanal',
consultant: 'Berater',
};
/** 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',
provider: 'Vermittler und Portale, z. B. Hays',
channel: 'Auf welchem Weg das Projekt kam',
consultant: 'Eigene Berater, für die Projekte gesucht werden',
};
export interface AcquisitionOption {
id: number;
value: string;
}
export type AcquisitionOptions = Record<AcquisitionOptionField, AcquisitionOption[]>;
export const EMPTY_ACQUISITION_OPTIONS: AcquisitionOptions = {
sender: [],
provider: [],
channel: [],
consultant: [],
};
/**
* Ein gefundenes Projekt, bevor daraus eine Opportunity wird.
@@ -280,7 +303,7 @@ export interface Acquisition {
sender: string | null;
/** "Anbieter": Vermittler oder Portal. */
provider: string | null;
channel: AcquisitionChannel | null;
channel: string | null;
title: string;
/** "Berater", fuer den es passt. */
consultant: string | null;
@@ -312,8 +335,8 @@ 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[] };
/** Inhalte der Dropdowns; kommen mit, damit die Eingabe sie sofort hat. */
options: AcquisitionOptions;
entries: Acquisition[];
}