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,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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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