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:
co-authored by
Claude Opus 5
parent
b8a78f609c
commit
05cc261900
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />}
|
||||
|
||||
Reference in New Issue
Block a user