Files
perinco-CRM/src/components/settings/AcquisitionSettings.tsx
T
Sascha WolfandClaude Opus 5 23087a12fb fix(akquise): Anbieter wieder als Freitext mit Vorschlaegen
Das Dropdown fiel dem Feld zur Last: neue Vermittler tauchen zu oft auf, als
dass das Eintragen an einer gepflegten Liste haengen sollte. Anbieter ist wieder
ein Textfeld, jetzt mit Vorschlagsliste aus den bisherigen Eintraegen - der
zuletzt benutzte zuerst.

In den Einstellungen bleiben die drei Listen "Von", "Kanal" und "Berater".
dropProviderOptions() nimmt 'provider' aus dem ENUM der Optionstabelle und
loescht die dort gepflegten Werte; die Anbieter der Akquise-Eintraege bleiben
unberuehrt, sie stehen als Text in crm_acquisitions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 12:21:33 +02:00

314 lines
11 KiB
TypeScript

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", "Kanal", "Berater".
*
* "Anbieter" steht bewusst nicht dabei - das Feld ist Freitext mit Vorschlaegen
* aus den bisherigen Eintraegen.
*
* Gleich aufgebaute Listen statt 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: '',
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 drei Listen füllen die Dropdowns <em>Von</em>, <em>Kanal</em> und <em>Berater</em> im
Formular unter <em>Akquise</em>. Pflegen darf jeder angemeldete Benutzer - wer beim
Eintragen einen fehlenden Wert braucht, soll weiterarbeiten können. Das Feld
<em> Anbieter</em> steht bewusst nicht hier: dort wird frei getippt, mit Vorschlägen aus
den bisherigen Einträgen.
</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>
);
};