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,20 @@
-- Werbe-Beziehung zwischen Kunden ("Geworben / angeworben").
-- recruitedId ist UNIQUE → ein Kunde kann nur von EINER Person geworben sein.
-- Beide FKs mit ON DELETE CASCADE: wird ein Kunde gelöscht, verschwinden
-- seine Werbe-Verknüpfungen mit.
CREATE TABLE IF NOT EXISTS `CustomerReferral` (
`id` INT NOT NULL AUTO_INCREMENT,
`recruiterId` INT NOT NULL,
`recruitedId` INT NOT NULL,
`relationship` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`createdBy` VARCHAR(191) NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `CustomerReferral_recruitedId_key` (`recruitedId`),
INDEX `CustomerReferral_recruiterId_idx` (`recruiterId`),
CONSTRAINT `CustomerReferral_recruiterId_fkey` FOREIGN KEY (`recruiterId`)
REFERENCES `Customer` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `CustomerReferral_recruitedId_fkey` FOREIGN KEY (`recruitedId`)
REFERENCES `Customer` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+24
View File
@@ -213,10 +213,34 @@ model Customer {
// Persönliche Anrede-Präferenzen aller Mitarbeiter zu diesem Kunden.
userSalutations UserCustomerSalutation[]
// Werbung: wer hat wen an Board geholt.
// referralAsRecruited = dieser Kunde WURDE geworben (max. 1 Werber).
// referralsAsRecruiter = Kunden, die DIESER Kunde geworben hat (beliebig viele).
referralAsRecruited CustomerReferral? @relation("ReferralRecruited")
referralsAsRecruiter CustomerReferral[] @relation("ReferralRecruiter")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Werbe-Beziehung zwischen zwei Kunden ("Geworben / angeworben").
// Ein Datensatz = ein Werber (recruiter) hat einen Kunden (recruited) an
// Board geholt, plus die Beziehungsart. `recruitedId` ist @unique, damit ein
// Kunde nur von EINER Person geworben sein kann (Abschnitt 1 in der UI).
// Ein Werber kann beliebig viele Kunden werben (Abschnitt 2).
model CustomerReferral {
id Int @id @default(autoincrement())
recruiterId Int
recruiter Customer @relation("ReferralRecruiter", fields: [recruiterId], references: [id], onDelete: Cascade)
recruitedId Int @unique
recruited Customer @relation("ReferralRecruited", fields: [recruitedId], references: [id], onDelete: Cascade)
relationship String // Ehepartner/in, Kind, Freund/Kumpel, …
createdAt DateTime @default(now())
createdBy String?
@@index([recruiterId])
}
// ==================== USER-CUSTOMER SALUTATION ====================
// Per-User-Präferenz, ob der eingeloggte Mitarbeiter mit dem jeweiligen
// Kunden per Du oder Sie ist. Fehlender Eintrag → fällt auf den
@@ -0,0 +1,133 @@
import { Response } from 'express';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import * as referralService from '../services/customerReferral.service.js';
// Der ganze Bereich ist rein für Mitarbeiter/Admins Portal-Kunden dürfen
// Werbe-Beziehungen weder sehen noch ändern. Defense-in-Depth: nicht nur die
// UI blendet den Tab aus, der Endpoint blockt Portal-Token explizit.
function blockPortal(req: AuthRequest, res: Response): boolean {
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
return true;
}
return false;
}
export async function getReferrals(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const customerId = parseInt(req.params.customerId);
if (Number.isNaN(customerId)) {
res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' } as ApiResponse);
return;
}
const data = await referralService.getReferralsForCustomer(customerId);
res.json({ success: true, data } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Werbe-Beziehungen' } as ApiResponse);
}
}
export async function searchCustomers(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const customerId = parseInt(req.params.customerId);
if (Number.isNaN(customerId)) {
res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' } as ApiResponse);
return;
}
const search = req.query.search;
if (!search || typeof search !== 'string' || search.trim().length < 2) {
res.json({ success: true, data: [] } as ApiResponse);
return;
}
const customers = await referralService.searchCustomersForReferral(search.trim(), customerId);
res.json({ success: true, data: customers } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler bei der Suche' } as ApiResponse);
}
}
export async function createReferral(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const customerId = parseInt(req.params.customerId);
if (Number.isNaN(customerId)) {
res.status(400).json({ success: false, error: 'Ungültige Kunden-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;
}
// direction bestimmt, wer Werber und wer Geworbener ist:
// 'recruitedBy' = dieser Kunde wurde vom anderen geworben (Abschnitt 1)
// 'recruited' = dieser Kunde hat den anderen geworben (Abschnitt 2)
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 created = await referralService.createReferral({
recruiterId,
recruitedId,
relationship: (relationship ?? '').toString(),
createdBy: req.user?.email,
});
await logChange({
req,
action: 'CREATE',
resourceType: 'CustomerReferral',
resourceId: created.id.toString(),
label: `Werbe-Beziehung angelegt: Kunde #${recruiterId} hat Kunde #${recruitedId} geworben (${created.relationship})`,
customerId: recruitedId,
});
res.status(201).json({ success: true, data: created } 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 Anlegen der Werbe-Beziehung',
} as ApiResponse);
}
}
export async function deleteReferral(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const referralId = parseInt(req.params.referralId);
if (Number.isNaN(referralId)) {
res.status(400).json({ success: false, error: 'Ungültige ID' } as ApiResponse);
return;
}
const ok = await referralService.deleteReferral(referralId);
if (!ok) {
res.status(404).json({ success: false, error: 'Eintrag nicht gefunden' } as ApiResponse);
return;
}
await logChange({
req,
action: 'DELETE',
resourceType: 'CustomerReferral',
resourceId: referralId.toString(),
label: `Werbe-Beziehung #${referralId} entfernt`,
});
res.json({ success: true } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Entfernen der Werbe-Beziehung' } as ApiResponse);
}
}
+9
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import * as customerController from '../controllers/customer.controller.js';
import * as customerReferralController from '../controllers/customerReferral.controller.js';
import * as stressfreiEmailController from '../controllers/stressfreiEmail.controller.js';
import { authenticate, requirePermission, requireCustomerAccess } from '../middleware/auth.js';
@@ -53,4 +54,12 @@ router.post('/:customerId/representatives', authenticate, requirePermission('cus
router.delete('/:customerId/representatives/:representativeId', authenticate, requirePermission('customers:update'), customerController.removeRepresentative);
router.get('/:customerId/representatives/search', authenticate, requirePermission('customers:read'), customerController.searchForRepresentative);
// Werbung ("Geworben / angeworben") nur Mitarbeiter/Admins (Controller
// blockt Portal zusätzlich explizit). /search VOR der Liste ist unkritisch,
// da unterschiedliche Pfade, aber der Klarheit halber gruppiert.
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.delete('/:customerId/referrals/:referralId', authenticate, requirePermission('customers:update'), customerReferralController.deleteReferral);
export default router;
@@ -0,0 +1,140 @@
// ==================== CUSTOMER REFERRAL SERVICE ====================
// "Geworben / angeworben": wer hat wen an Board geholt.
import prisma from '../lib/prisma.js';
// Erlaubte Beziehungsarten (Whitelist). Server vertraut dem Frontend-Dropdown
// nicht ein handgebauter Request mit beliebigem String wird abgewiesen.
export const REFERRAL_RELATIONSHIPS = [
'Ehepartner/in',
'Lebenspartner/in',
'Kind',
'Enkelkind',
'Onkel/Tante',
'Neffe',
'Freund/Kumpel',
'Eltern',
'Bruder/Schwester',
'Bekannte',
'sonstige',
] as const;
const CUSTOMER_SELECT = {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
type: true,
} as const;
/**
* Liefert für einen Kunden beide Richtungen:
* - recruitedBy: von wem dieser Kunde geworben wurde (max. 1)
* - recruited: welche Kunden dieser Kunde geworben hat (beliebig viele)
*/
export async function getReferralsForCustomer(customerId: number) {
const [recruitedBy, recruited] = await Promise.all([
prisma.customerReferral.findUnique({
where: { recruitedId: customerId },
include: { recruiter: { select: CUSTOMER_SELECT } },
}),
prisma.customerReferral.findMany({
where: { recruiterId: customerId },
include: { recruited: { select: CUSTOMER_SELECT } },
orderBy: { createdAt: 'asc' },
}),
]);
return {
// Abschnitt 1: "<Kunde> wurde an Board geholt durch:"
recruitedBy: recruitedBy
? { id: recruitedBy.id, relationship: recruitedBy.relationship, customer: recruitedBy.recruiter }
: null,
// Abschnitt 2: "<Kunde> hat folgende Kunden an Board geholt:"
recruited: recruited.map((r) => ({
id: r.id,
relationship: r.relationship,
customer: r.recruited,
})),
};
}
export class ReferralError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.status = status;
}
}
/**
* Legt eine Werbe-Beziehung an: `recruiterId` hat `recruitedId` an Board geholt.
*/
export async function createReferral(params: {
recruiterId: number;
recruitedId: number;
relationship: string;
createdBy?: string;
}) {
const { recruiterId, recruitedId, relationship, createdBy } = params;
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');
}
// 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 → höchstens ein Werber. Vorab prüfen für klare Meldung.
const existing = await prisma.customerReferral.findUnique({ where: { recruitedId } });
if (existing) {
throw new ReferralError('Dieser Kunde wurde bereits als angeworben markiert. Bitte zuerst den bestehenden Eintrag entfernen.', 409);
}
const created = await prisma.customerReferral.create({
data: { recruiterId, recruitedId, relationship, createdBy },
include: { recruiter: { select: CUSTOMER_SELECT }, recruited: { select: CUSTOMER_SELECT } },
});
return created;
}
export async function deleteReferral(id: number): Promise<boolean> {
if (!Number.isInteger(id)) return false;
const existing = await prisma.customerReferral.findUnique({ where: { id } });
if (!existing) return false;
await prisma.customerReferral.delete({ where: { id } });
return true;
}
/**
* Kundensuche für die Werber-Auswahl. Breit über die wichtigsten
* Kundenakten-Felder, schließt den eigenen Kunden aus.
*/
export async function searchCustomersForReferral(search: string, excludeCustomerId: number) {
return prisma.customer.findMany({
where: {
id: { not: excludeCustomerId },
OR: [
{ firstName: { contains: search } },
{ lastName: { contains: search } },
{ companyName: { contains: search } },
{ customerNumber: { contains: search } },
{ email: { contains: search } },
{ phone: { contains: search } },
{ mobile: { contains: search } },
],
},
select: CUSTOMER_SELECT,
take: 10,
});
}
+19
View File
@@ -97,6 +97,25 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🤝 Kundenakte: Tab „Geworben / angeworben" (Kundenempfehlungen)**
- Neuer Tab vor „Datenschutz", **nur Mitarbeiter/Admin** (nicht Portal),
**ohne** Consent-Pflicht nutzbar. 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-Modal gesucht, breite Suche über
Name/Nr./Firma/E-Mail/Telefon) + Beziehungs-Dropdown (Ehepartner/in,
Kind, Freund/Kumpel, … „Bitte auswählen!"). Löschen + Externtab-Link
zur Kundenakte pro Zeile.
- **Bidirektional aus einem Datensatz:** trägt man in Kunde A „geworben
durch B" ein, erscheint A automatisch bei B unter „hat geworben".
Von beiden Akten hinzufügbar/löschbar.
- Neues Model `CustomerReferral` (recruiter/recruited FKs, `recruitedId`
@unique, `relationship`) + Migration. Beziehungs-Whitelist server-
seitig; Self-Werbung + Doppel-Werber (409) abgefangen; Portal-Token
explizit geblockt (Defense-in-Depth, nicht nur UI). CREATE/DELETE
auditiert.
- [x] **🔄 „Neue Version verfügbar"-Banner (offener-Tab-Problem)**
- Cache-Header waren schon optimal (index.html `no-store`, Assets
`immutable`) aber eine laufende SPA holt sich nach einem Deploy
@@ -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;