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.
|
// Persönliche Anrede-Präferenzen aller Mitarbeiter zu diesem Kunden.
|
||||||
userSalutations UserCustomerSalutation[]
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 ====================
|
// ==================== USER-CUSTOMER SALUTATION ====================
|
||||||
// Per-User-Präferenz, ob der eingeloggte Mitarbeiter mit dem jeweiligen
|
// Per-User-Präferenz, ob der eingeloggte Mitarbeiter mit dem jeweiligen
|
||||||
// Kunden per Du oder Sie ist. Fehlender Eintrag → fällt auf den
|
// 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 { Router } from 'express';
|
||||||
import * as customerController from '../controllers/customer.controller.js';
|
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 * as stressfreiEmailController from '../controllers/stressfreiEmail.controller.js';
|
||||||
import { authenticate, requirePermission, requireCustomerAccess } from '../middleware/auth.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.delete('/:customerId/representatives/:representativeId', authenticate, requirePermission('customers:update'), customerController.removeRepresentative);
|
||||||
router.get('/:customerId/representatives/search', authenticate, requirePermission('customers:read'), customerController.searchForRepresentative);
|
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;
|
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
|
## ✅ 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)**
|
- [x] **🔄 „Neue Version verfügbar"-Banner (offener-Tab-Problem)**
|
||||||
- Cache-Header waren schon optimal (index.html `no-store`, Assets
|
- Cache-Header waren schon optimal (index.html `no-store`, Assets
|
||||||
`immutable`) – aber eine laufende SPA holt sich nach einem Deploy
|
`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 toast from 'react-hot-toast';
|
||||||
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
|
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
|
||||||
import { EmailClientTab } from '../../components/email';
|
import { EmailClientTab } from '../../components/email';
|
||||||
|
import ReferralsTab from '../../components/customers/ReferralsTab';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import Button from '../../components/ui/Button';
|
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 ? [{
|
...(hasPermission('customers:read') && !isCustomerPortal ? [{
|
||||||
id: 'consents',
|
id: 'consents',
|
||||||
label: 'Einwilligungen / Datenschutz',
|
label: 'Einwilligungen / Datenschutz',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
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
|
// 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
|
// Addresses
|
||||||
export const addressApi = {
|
export const addressApi = {
|
||||||
getByCustomer: async (customerId: number) => {
|
getByCustomer: async (customerId: number) => {
|
||||||
|
|||||||
@@ -25,6 +25,34 @@ export interface CustomerSummary {
|
|||||||
portalEnabled?: boolean;
|
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 {
|
export interface RepresentativeAuthorization {
|
||||||
id: number;
|
id: number;
|
||||||
customerId: number;
|
customerId: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user