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:
@@ -106,6 +106,61 @@ export async function createReferral(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateReferral(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
const referralId = parseInt(req.params.referralId);
|
||||
if (Number.isNaN(customerId) || Number.isNaN(referralId)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültige ID' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const { direction, otherCustomerId, relationship } = req.body ?? {};
|
||||
const otherId = parseInt(otherCustomerId);
|
||||
if (Number.isNaN(otherId)) {
|
||||
res.status(400).json({ success: false, error: 'Kein Kunde ausgewählt' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
let recruiterId: number;
|
||||
let recruitedId: number;
|
||||
if (direction === 'recruitedBy') {
|
||||
recruiterId = otherId;
|
||||
recruitedId = customerId;
|
||||
} else if (direction === 'recruited') {
|
||||
recruiterId = customerId;
|
||||
recruitedId = otherId;
|
||||
} else {
|
||||
res.status(400).json({ success: false, error: 'Ungültige Richtung' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await referralService.updateReferral({
|
||||
id: referralId,
|
||||
recruiterId,
|
||||
recruitedId,
|
||||
relationship: (relationship ?? '').toString(),
|
||||
});
|
||||
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'CustomerReferral',
|
||||
resourceId: updated.id.toString(),
|
||||
label: `Werbe-Beziehung geändert: Kunde #${recruiterId} hat Kunde #${recruitedId} geworben (${updated.relationship})`,
|
||||
customerId: recruitedId,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: updated } as ApiResponse);
|
||||
} catch (error) {
|
||||
const status = (error as referralService.ReferralError)?.status ?? 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Ändern der Werbe-Beziehung',
|
||||
} as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteReferral(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
|
||||
@@ -60,6 +60,7 @@ router.get('/:customerId/representatives/search', authenticate, requirePermissio
|
||||
router.get('/:customerId/referrals/search', authenticate, requirePermission('customers:read'), customerReferralController.searchCustomers);
|
||||
router.get('/:customerId/referrals', authenticate, requirePermission('customers:read'), customerReferralController.getReferrals);
|
||||
router.post('/:customerId/referrals', authenticate, requirePermission('customers:update'), customerReferralController.createReferral);
|
||||
router.put('/:customerId/referrals/:referralId', authenticate, requirePermission('customers:update'), customerReferralController.updateReferral);
|
||||
router.delete('/:customerId/referrals/:referralId', authenticate, requirePermission('customers:update'), customerReferralController.deleteReferral);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -15,6 +15,10 @@ export const REFERRAL_RELATIONSHIPS = [
|
||||
'Freund/Kumpel',
|
||||
'Eltern',
|
||||
'Bruder/Schwester',
|
||||
'Schwiegertochter/Schwiegersohn',
|
||||
'Schwiegermutter/Schwiegervater',
|
||||
'Oma/Opa',
|
||||
'Uroma/Uropa',
|
||||
'Bekannte',
|
||||
'sonstige',
|
||||
] as const;
|
||||
@@ -108,6 +112,60 @@ export async function createReferral(params: {
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ändert eine bestehende Werbe-Beziehung (Beziehungsart und/oder den
|
||||
* beteiligten Gegen-Kunden). recruiter/recruited werden vom Controller aus
|
||||
* der Richtung + aktueller Kundenakte bestimmt.
|
||||
*/
|
||||
export async function updateReferral(params: {
|
||||
id: number;
|
||||
recruiterId: number;
|
||||
recruitedId: number;
|
||||
relationship: string;
|
||||
}) {
|
||||
const { id, recruiterId, recruitedId, relationship } = params;
|
||||
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new ReferralError('Ungültige ID');
|
||||
}
|
||||
if (!Number.isInteger(recruiterId) || !Number.isInteger(recruitedId)) {
|
||||
throw new ReferralError('Ungültige Kunden-ID');
|
||||
}
|
||||
if (recruiterId === recruitedId) {
|
||||
throw new ReferralError('Ein Kunde kann sich nicht selbst werben');
|
||||
}
|
||||
if (!REFERRAL_RELATIONSHIPS.includes(relationship as typeof REFERRAL_RELATIONSHIPS[number])) {
|
||||
throw new ReferralError('Ungültige Beziehungsart');
|
||||
}
|
||||
|
||||
const existing = await prisma.customerReferral.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new ReferralError('Eintrag nicht gefunden', 404);
|
||||
}
|
||||
|
||||
// Beide Kunden müssen existieren
|
||||
const count = await prisma.customer.count({ where: { id: { in: [recruiterId, recruitedId] } } });
|
||||
if (count !== 2) {
|
||||
throw new ReferralError('Kunde nicht gefunden', 404);
|
||||
}
|
||||
|
||||
// recruitedId ist @unique → falls der Geworbene auf einen anderen Kunden
|
||||
// geändert wird, der bereits einen Werber hat, klare Meldung statt DB-Fehler.
|
||||
if (recruitedId !== existing.recruitedId) {
|
||||
const clash = await prisma.customerReferral.findUnique({ where: { recruitedId } });
|
||||
if (clash && clash.id !== id) {
|
||||
throw new ReferralError('Dieser Kunde wurde bereits als angeworben markiert. Bitte zuerst den bestehenden Eintrag entfernen.', 409);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.customerReferral.update({
|
||||
where: { id },
|
||||
data: { recruiterId, recruitedId, relationship },
|
||||
include: { recruiter: { select: CUSTOMER_SELECT }, recruited: { select: CUSTOMER_SELECT } },
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteReferral(id: number): Promise<boolean> {
|
||||
if (!Number.isInteger(id)) return false;
|
||||
const existing = await prisma.customerReferral.findUnique({ where: { id } });
|
||||
|
||||
@@ -115,6 +115,12 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
seitig; Self-Werbung + Doppel-Werber (409) abgefangen; Portal-Token
|
||||
explizit geblockt (Defense-in-Depth, nicht nur UI). CREATE/DELETE
|
||||
auditiert.
|
||||
- **Nachtrag 2026-07-30:** Beziehungs-Dropdown um Schwiegertochter/
|
||||
Schwiegersohn, Schwiegermutter/Schwiegervater, Oma/Opa, Uroma/Uropa
|
||||
erweitert (Whitelist front- & backend synchron). Bearbeiten-Stift pro
|
||||
Zeile (vor der Mülltonne): ändert Beziehung und/oder Gegen-Kunden;
|
||||
neuer `PUT /:customerId/referrals/:referralId` (Whitelist, Doppel-
|
||||
Werber-409 mit Self-Ausschluss, Portal-Block, UPDATE auditiert).
|
||||
|
||||
- [x] **🔄 „Neue Version verfügbar"-Banner (offener-Tab-Problem)**
|
||||
- Cache-Header waren schon optimal (index.html `no-store`, Assets
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -249,6 +249,14 @@ export const referralApi = {
|
||||
const res = await api.post<ApiResponse<unknown>>(`/customers/${customerId}/referrals`, payload);
|
||||
return res.data;
|
||||
},
|
||||
update: async (
|
||||
customerId: number,
|
||||
referralId: number,
|
||||
payload: { direction: 'recruitedBy' | 'recruited'; otherCustomerId: number; relationship: string },
|
||||
) => {
|
||||
const res = await api.put<ApiResponse<unknown>>(`/customers/${customerId}/referrals/${referralId}`, payload);
|
||||
return res.data;
|
||||
},
|
||||
remove: async (customerId: number, referralId: number) => {
|
||||
const res = await api.delete<ApiResponse<void>>(`/customers/${customerId}/referrals/${referralId}`);
|
||||
return res.data;
|
||||
|
||||
@@ -36,6 +36,10 @@ export const REFERRAL_RELATIONSHIPS = [
|
||||
'Freund/Kumpel',
|
||||
'Eltern',
|
||||
'Bruder/Schwester',
|
||||
'Schwiegertochter/Schwiegersohn',
|
||||
'Schwiegermutter/Schwiegervater',
|
||||
'Oma/Opa',
|
||||
'Uroma/Uropa',
|
||||
'Bekannte',
|
||||
'sonstige',
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user