Kundenakte: Tab "Geworben / angeworben" (Kundenempfehlungen)

Neuer Tab vor "Datenschutz", nur Mitarbeiter/Admin (nicht Portal),
ohne Consent-Pflicht. Zwei Abschnitte:
1. "<Kunde> wurde an Board geholt durch:" – max. 1 Werber
   (DB-Unique auf recruitedId).
2. "<Kunde> hat folgende Kunden an Board geholt:" – beliebig viele.

Jede Zeile: Kunde per Lupe-Such-Modal (breite Suche über Name/
Kundennr./Firma/E-Mail/Telefon) + Beziehungs-Dropdown. Löschen +
Externtab-Link zur Kundenakte pro Zeile.

Bidirektional aus EINEM Datensatz: "A geworben durch B" erscheint
automatisch bei B unter "hat geworben"; von beiden Akten
hinzufügbar/löschbar.

Backend: neues Model CustomerReferral (recruiter/recruited FKs,
recruitedId @unique, relationship) + Migration. Beziehungs-Whitelist
serverseitig; Self-Werbung + Doppel-Werber (409) abgefangen.
Portal-Token wird explizit geblockt (Defense-in-Depth, nicht nur
UI-Ausblendung). CREATE/DELETE auditiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 17:50:15 +02:00
co-authored by Claude Opus 4.7
parent 6b08762ff9
commit cda6d2814e
10 changed files with 744 additions and 1 deletions
@@ -0,0 +1,333 @@
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 toast from 'react-hot-toast';
import { referralApi } from '../../services/api';
import { REFERRAL_RELATIONSHIPS } from '../../types';
import type { CustomerSummary, ReferralEntry } from '../../types';
import Modal from '../ui/Modal';
import Input from '../ui/Input';
import Select from '../ui/Select';
interface ReferralsTabProps {
customerId: number;
customerName: string;
canEdit: boolean;
}
function customerLabel(c: CustomerSummary): string {
const name = c.type === 'BUSINESS' && c.companyName ? c.companyName : `${c.firstName} ${c.lastName}`;
return `${name} (${c.customerNumber})`;
}
// Modal zum Suchen und Auswählen eines Kunden.
function CustomerSearchModal({
customerId,
isOpen,
onClose,
onSelect,
}: {
customerId: number;
isOpen: boolean;
onClose: () => void;
onSelect: (c: CustomerSummary) => void;
}) {
const [term, setTerm] = useState('');
const [results, setResults] = useState<CustomerSummary[]>([]);
const [searching, setSearching] = useState(false);
const runSearch = async (value: string) => {
setTerm(value);
if (value.trim().length < 2) {
setResults([]);
return;
}
setSearching(true);
try {
const res = await referralApi.search(customerId, value.trim());
setResults(res.data || []);
} catch {
setResults([]);
} finally {
setSearching(false);
}
};
const handleClose = () => {
setTerm('');
setResults([]);
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Kunde suchen" size="lg">
<div className="space-y-3">
<Input
autoFocus
placeholder="Name, Kundennummer, Firma, E-Mail, Telefon …"
value={term}
onChange={(e) => runSearch(e.target.value)}
/>
<p className="text-xs text-gray-500">Mindestens 2 Zeichen eingeben.</p>
<div className="max-h-72 overflow-y-auto border rounded-lg divide-y">
{searching ? (
<div className="p-3 text-center text-gray-500 text-sm">Suche </div>
) : results.length > 0 ? (
results.map((c) => (
<button
key={c.id}
onClick={() => {
onSelect(c);
handleClose();
}}
className="w-full text-left p-3 hover:bg-blue-50 text-sm flex items-center justify-between gap-2"
>
<span>
<span className="font-medium">
{c.type === 'BUSINESS' && c.companyName ? c.companyName : `${c.firstName} ${c.lastName}`}
</span>
<span className="text-gray-500 ml-2 font-mono text-xs">{c.customerNumber}</span>
</span>
</button>
))
) : term.trim().length >= 2 ? (
<div className="p-3 text-center text-gray-500 text-sm">Keine Kunden gefunden.</div>
) : (
<div className="p-3 text-center text-gray-400 text-sm">Suchbegriff eingeben </div>
)}
</div>
</div>
</Modal>
);
}
// Eine Draft-Zeile: Kunde per Lupe wählen + Beziehung, dann speichern.
function DraftRow({
customerId,
direction,
onCancel,
onSaved,
}: {
customerId: number;
direction: 'recruitedBy' | 'recruited';
onCancel: () => void;
onSaved: () => void;
}) {
const [selected, setSelected] = useState<CustomerSummary | null>(null);
const [relationship, setRelationship] = useState('');
const [showSearch, setShowSearch] = useState(false);
const saveMutation = useMutation({
mutationFn: () =>
referralApi.create(customerId, {
direction,
otherCustomerId: selected!.id,
relationship,
}),
onSuccess: () => {
onSaved();
},
onError: (err: Error) => {
toast.error(err.message || 'Speichern fehlgeschlagen');
},
});
return (
<div className="flex items-center gap-2 py-2 border-b bg-blue-50/40 px-2 rounded">
<div className="flex-1 flex items-center gap-2">
<span className={`text-sm ${selected ? 'font-medium' : 'text-gray-400 italic'}`}>
{selected ? customerLabel(selected) : 'Kunde wählen …'}
</span>
<button
onClick={() => setShowSearch(true)}
className="text-gray-400 hover:text-blue-600 p-1 rounded"
title="Kunde suchen"
>
<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={!selected || !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={onCancel} 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>
);
}
// Eine gespeicherte Zeile: Kunde (Link) + Beziehung + löschen + Externtab.
function SavedRow({
customerId,
entry,
canEdit,
onDeleted,
}: {
customerId: number;
entry: ReferralEntry;
canEdit: boolean;
onDeleted: () => void;
}) {
const deleteMutation = useMutation({
mutationFn: () => referralApi.remove(customerId, entry.id),
onSuccess: () => onDeleted(),
onError: (err: Error) => toast.error(err.message || 'Löschen fehlgeschlagen'),
});
return (
<div className="flex items-center gap-2 py-2 border-b px-2">
<div className="flex-1 flex items-center gap-2">
<Link
to={`/customers/${entry.customer.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-600 hover:underline"
>
{customerLabel(entry.customer)}
</Link>
<a
href={`/customers/${entry.customer.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-blue-600 p-1 rounded"
title="Kundenakte in neuem Tab öffnen"
>
<ExternalLink className="w-4 h-4" />
</a>
</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>
) : (
<span className="w-6" />
)}
</div>
);
}
export default function ReferralsTab({ customerId, customerName, canEdit }: ReferralsTabProps) {
const queryClient = useQueryClient();
const [addingRecruitedBy, setAddingRecruitedBy] = useState(false);
const [addingRecruited, setAddingRecruited] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['customer-referrals', customerId],
queryFn: () => referralApi.get(customerId),
});
const refresh = () => queryClient.invalidateQueries({ queryKey: ['customer-referrals', customerId] });
if (isLoading) {
return <div className="text-center py-4 text-gray-500">Laden </div>;
}
const referrals = data?.data;
const recruitedBy = referrals?.recruitedBy ?? null;
const recruited = referrals?.recruited ?? [];
// Spalten-Kopf für beide Abschnitte
const columnHeader = (
<div className="flex items-center gap-2 px-2 pb-1 text-xs font-medium text-gray-500 uppercase tracking-wide">
<div className="flex-1">Kunde</div>
<div className="w-56">Beziehung</div>
<div className="w-6" />
</div>
);
return (
<div className="space-y-8">
{/* Abschnitt 1: wurde geworben durch (max. 1) */}
<div>
<h3 className="font-medium text-gray-800 mb-3">
{customerName}, wurde an Board geholt durch:
</h3>
{recruitedBy || addingRecruitedBy ? columnHeader : null}
{recruitedBy ? (
<SavedRow customerId={customerId} entry={recruitedBy} canEdit={canEdit} onDeleted={refresh} />
) : addingRecruitedBy ? (
<DraftRow
customerId={customerId}
direction="recruitedBy"
onCancel={() => setAddingRecruitedBy(false)}
onSaved={() => {
setAddingRecruitedBy(false);
refresh();
}}
/>
) : (
<p className="text-sm text-gray-400 italic px-2 py-1">Noch kein Eintrag.</p>
)}
{/* + nur wenn noch kein Werber gesetzt ist (max. 1) */}
{canEdit && !recruitedBy && !addingRecruitedBy && (
<button
onClick={() => setAddingRecruitedBy(true)}
className="mt-2 inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
>
<Plus className="w-4 h-4" /> Hinzufügen
</button>
)}
</div>
{/* Abschnitt 2: hat folgende Kunden geworben (beliebig viele) */}
<div>
<h3 className="font-medium text-gray-800 mb-3">
{customerName}, hat folgende Kunden an Board geholt:
</h3>
{recruited.length > 0 || addingRecruited ? columnHeader : null}
{recruited.map((entry) => (
<SavedRow key={entry.id} customerId={customerId} entry={entry} canEdit={canEdit} onDeleted={refresh} />
))}
{addingRecruited && (
<DraftRow
customerId={customerId}
direction="recruited"
onCancel={() => setAddingRecruited(false)}
onSaved={() => {
setAddingRecruited(false);
refresh();
}}
/>
)}
{recruited.length === 0 && !addingRecruited && (
<p className="text-sm text-gray-400 italic px-2 py-1">Noch keine Einträge.</p>
)}
{canEdit && !addingRecruited && (
<button
onClick={() => setAddingRecruited(true)}
className="mt-2 inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800"
>
<Plus className="w-4 h-4" /> Hinzufügen
</button>
)}
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
import { EmailClientTab } from '../../components/email';
import ReferralsTab from '../../components/customers/ReferralsTab';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
@@ -193,6 +194,19 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
/>
),
}] : []),
...(hasPermission('customers:read') && !isCustomerPortal ? [{
id: 'referrals',
label: 'Geworben / angeworben',
// Bewusst ohne Consent-Gate (hasConsentApproval): funktioniert auch
// ohne Datenschutz-Einwilligung des Kunden. Nur Staff, nie Portal.
content: (
<ReferralsTab
customerId={customerId}
customerName={c.type === 'BUSINESS' && c.companyName ? c.companyName : `${c.firstName} ${c.lastName}`}
canEdit={hasPermission('customers:update')}
/>
),
}] : []),
...(hasPermission('customers:read') && !isCustomerPortal ? [{
id: 'consents',
label: 'Einwilligungen / Datenschutz',
+24 -1
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, ContractHistoryEntry, AuditLog, AuditSensitivity, AuditRetentionPolicy, CustomerConsent, ConsentType, ConsentStatus, DataDeletionRequest, DeletionRequestStatus, GDPRDashboardStats, RepresentativeAuthorization } from '../types';
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, CustomerReferrals, ContractHistoryEntry, AuditLog, AuditSensitivity, AuditRetentionPolicy, CustomerConsent, ConsentType, ConsentStatus, DataDeletionRequest, DeletionRequestStatus, GDPRDashboardStats, RepresentativeAuthorization } from '../types';
// ============================================================================
// In-Memory-Token-Store
@@ -232,6 +232,29 @@ export const customerApi = {
},
};
// Werbung ("Geworben / angeworben")
export const referralApi = {
get: async (customerId: number) => {
const res = await api.get<ApiResponse<CustomerReferrals>>(`/customers/${customerId}/referrals`);
return res.data;
},
search: async (customerId: number, search: string) => {
const res = await api.get<ApiResponse<CustomerSummary[]>>(`/customers/${customerId}/referrals/search`, { params: { search } });
return res.data;
},
create: async (
customerId: number,
payload: { direction: 'recruitedBy' | 'recruited'; otherCustomerId: number; relationship: string },
) => {
const res = await api.post<ApiResponse<unknown>>(`/customers/${customerId}/referrals`, payload);
return res.data;
},
remove: async (customerId: number, referralId: number) => {
const res = await api.delete<ApiResponse<void>>(`/customers/${customerId}/referrals/${referralId}`);
return res.data;
},
};
// Addresses
export const addressApi = {
getByCustomer: async (customerId: number) => {
+28
View File
@@ -25,6 +25,34 @@ export interface CustomerSummary {
portalEnabled?: boolean;
}
// Werbung ("Geworben / angeworben")
export const REFERRAL_RELATIONSHIPS = [
'Ehepartner/in',
'Lebenspartner/in',
'Kind',
'Enkelkind',
'Onkel/Tante',
'Neffe',
'Freund/Kumpel',
'Eltern',
'Bruder/Schwester',
'Bekannte',
'sonstige',
] as const;
export interface ReferralEntry {
id: number;
relationship: string;
customer: CustomerSummary;
}
export interface CustomerReferrals {
/** Von wem dieser Kunde geworben wurde (max. 1). */
recruitedBy: ReferralEntry | null;
/** Welche Kunden dieser Kunde geworben hat (beliebig viele). */
recruited: ReferralEntry[];
}
export interface RepresentativeAuthorization {
id: number;
customerId: number;