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:
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user