Referrals: 4 neue Beziehungen + Bearbeiten-Stift pro Eintrag

Beziehungs-Dropdown erweitert um Schwiegertochter/Schwiegersohn,
Schwiegermutter/Schwiegervater, Oma/Opa, Uroma/Uropa (Whitelist
front- und backend synchron).

Bearbeiten-Stift pro Zeile (vor der Muelltonne): Beziehung und/oder
Gegen-Kunde aenderbar. Neuer PUT /:customerId/referrals/:referralId
mit Whitelist-Pruefung, Doppel-Werber-409 (Self-Ausschluss),
Portal-Block und UPDATE-Auditeintrag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 10:57:04 +02:00
co-authored by Claude Opus 4.8
parent 2d8ee7f569
commit 81cd284cc4
7 changed files with 231 additions and 15 deletions
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Plus, Search, Trash2, ExternalLink, X, Check } from 'lucide-react';
import { Plus, Search, Trash2, ExternalLink, X, Check, Pencil } from 'lucide-react';
import toast from 'react-hot-toast';
import { referralApi } from '../../services/api';
import { REFERRAL_RELATIONSHIPS } from '../../types';
@@ -177,24 +177,99 @@ function DraftRow({
);
}
// Eine gespeicherte Zeile: Kunde (Link) + Beziehung + löschen + Externtab.
// Eine gespeicherte Zeile: Kunde (Link) + Beziehung + bearbeiten/löschen + Externtab.
function SavedRow({
customerId,
entry,
direction,
canEdit,
onDeleted,
onChanged,
}: {
customerId: number;
entry: ReferralEntry;
direction: 'recruitedBy' | 'recruited';
canEdit: boolean;
onDeleted: () => void;
onChanged: () => void;
}) {
const [editing, setEditing] = useState(false);
// Im Bearbeiten-Modus veränderbar: Gegen-Kunde und Beziehung.
const [selected, setSelected] = useState<CustomerSummary>(entry.customer);
const [relationship, setRelationship] = useState(entry.relationship);
const [showSearch, setShowSearch] = useState(false);
const startEdit = () => {
setSelected(entry.customer);
setRelationship(entry.relationship);
setEditing(true);
};
const deleteMutation = useMutation({
mutationFn: () => referralApi.remove(customerId, entry.id),
onSuccess: () => onDeleted(),
onSuccess: () => onChanged(),
onError: (err: Error) => toast.error(err.message || 'Löschen fehlgeschlagen'),
});
const saveMutation = useMutation({
mutationFn: () =>
referralApi.update(customerId, entry.id, {
direction,
otherCustomerId: selected.id,
relationship,
}),
onSuccess: () => {
setEditing(false);
onChanged();
},
onError: (err: Error) => toast.error(err.message || 'Speichern fehlgeschlagen'),
});
if (editing) {
return (
<div className="flex items-center gap-2 py-2 border-b bg-amber-50/50 px-2 rounded">
<div className="flex-1 flex items-center gap-2">
<span className="text-sm font-medium">{customerLabel(selected)}</span>
<button
onClick={() => setShowSearch(true)}
className="text-gray-400 hover:text-blue-600 p-1 rounded"
title="Anderen Kunden wählen"
>
<Search className="w-4 h-4" />
</button>
</div>
<div className="w-56">
<Select
value={relationship}
onChange={(e) => setRelationship(e.target.value)}
options={REFERRAL_RELATIONSHIPS.map((r) => ({ value: r, label: r }))}
placeholder="Bitte auswählen!"
/>
</div>
<button
onClick={() => saveMutation.mutate()}
disabled={!relationship || saveMutation.isPending}
className="text-green-600 hover:text-green-700 disabled:text-gray-300 p-1 rounded"
title="Speichern"
>
<Check className="w-5 h-5" />
</button>
<button
onClick={() => setEditing(false)}
className="text-gray-400 hover:text-red-600 p-1 rounded"
title="Abbrechen"
>
<X className="w-5 h-5" />
</button>
<CustomerSearchModal
customerId={customerId}
isOpen={showSearch}
onClose={() => setShowSearch(false)}
onSelect={(c) => setSelected(c)}
/>
</div>
);
}
return (
<div className="flex items-center gap-2 py-2 border-b px-2">
<div className="flex-1 flex items-center gap-2">
@@ -218,14 +293,23 @@ function SavedRow({
</div>
<div className="w-56 text-sm">{entry.relationship}</div>
{canEdit ? (
<button
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="text-gray-400 hover:text-red-600 p-1 rounded"
title="Eintrag entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
<>
<button
onClick={startEdit}
className="text-gray-400 hover:text-blue-600 p-1 rounded"
title="Eintrag bearbeiten"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="text-gray-400 hover:text-red-600 p-1 rounded"
title="Eintrag entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
</>
) : (
<span className="w-6" />
)}
@@ -271,7 +355,7 @@ export default function ReferralsTab({ customerId, customerName, canEdit }: Refe
</h3>
{recruitedBy || addingRecruitedBy ? columnHeader : null}
{recruitedBy ? (
<SavedRow customerId={customerId} entry={recruitedBy} canEdit={canEdit} onDeleted={refresh} />
<SavedRow customerId={customerId} entry={recruitedBy} direction="recruitedBy" canEdit={canEdit} onChanged={refresh} />
) : addingRecruitedBy ? (
<DraftRow
customerId={customerId}
@@ -303,7 +387,7 @@ export default function ReferralsTab({ customerId, customerName, canEdit }: Refe
</h3>
{recruited.length > 0 || addingRecruited ? columnHeader : null}
{recruited.map((entry) => (
<SavedRow key={entry.id} customerId={customerId} entry={entry} canEdit={canEdit} onDeleted={refresh} />
<SavedRow key={entry.id} customerId={customerId} entry={entry} direction="recruited" canEdit={canEdit} onChanged={refresh} />
))}
{addingRecruited && (
<DraftRow