From 9d6bd68ddcd5a6f0ba9faf4cfd1e2bc9cc25dfc6 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 23 Apr 2026 12:46:03 +0200 Subject: [PATCH] Geburtstag-Management-Modal mit Reset + Send + Auto-Flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Cake-Button neben dem Geburtsdatum in den Stammdaten öffnet ein Modal mit drei Funktionen: 1. **Gruß-Marker zurücksetzen** (lastBirthdayGreetingYear → null) - Für Debugging oder als Fallback, wenn der Kunde den Gruß erneut sehen soll - Mit Bestätigungsdialog 2. **Geburtstagsgruß jetzt senden** (Email / WhatsApp / Telegram / Signal) - Email: direkt via System-SMTP mit HTML-Template (Du/Sie-abhängig) - WhatsApp/Telegram/Signal: öffnet vorbefülltes Fenster mit Gruß-Text - Text beachtet Du/Sie-Verhältnis (pronomen, possessiv, etc.) - Mit Bestätigungsdialog 3. **Automatisch senden** – neue Einstellung am Customer - autoBirthdayGreeting (Boolean) + autoBirthdayChannel (String) - Für späteren Cron-basierten Automatik-Versand vorbereitet Backend: - birthday.service.ts: resetBirthdayGreeting, buildBirthdayGreetingText, getBirthdayGreetingData - birthday.controller.ts: resetBirthdayGreeting, sendBirthdayGreeting - Routes: POST /birthdays/:customerId/reset + /send - Audit-Log bei beiden Aktionen Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/prisma/schema.prisma | 4 + .../src/controllers/birthday.controller.ts | 125 ++++++++ backend/src/routes/birthday.routes.ts | 4 + backend/src/services/birthday.service.ts | 120 +++++++ backend/todo.md | 9 + .../components/BirthdayManagementModal.tsx | 302 ++++++++++++++++++ .../src/pages/customers/CustomerDetail.tsx | 21 +- frontend/src/services/api.ts | 11 + frontend/src/types/index.ts | 2 + 9 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/BirthdayManagementModal.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5b74149c..69051378 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -169,6 +169,10 @@ model Customer { // Anrede-Verhältnis: true = Du (informell), false = Sie (formell, Default) useInformalAddress Boolean @default(false) + // Automatischer Geburtstagsgruß-Versand + autoBirthdayGreeting Boolean @default(false) + autoBirthdayChannel String? // "email", "whatsapp", "telegram", "signal" + user User? addresses Address[] bankCards BankCard[] diff --git a/backend/src/controllers/birthday.controller.ts b/backend/src/controllers/birthday.controller.ts index f26b3da5..a8f5508f 100644 --- a/backend/src/controllers/birthday.controller.ts +++ b/backend/src/controllers/birthday.controller.ts @@ -1,6 +1,9 @@ import { Response } from 'express'; import { AuthRequest } from '../types/index.js'; import * as birthdayService from '../services/birthday.service.js'; +import { sendEmail, SmtpCredentials } from '../services/smtpService.js'; +import { getSystemEmailCredentials } from '../services/emailProvider/emailProviderService.js'; +import { createAuditLog } from '../services/audit.service.js'; /** * Admin/Mitarbeiter: Kommende und vergangene Geburtstage @@ -54,3 +57,125 @@ export async function acknowledgeMyBirthday(req: AuthRequest, res: Response) { res.status(500).json({ success: false, error: 'Fehler beim Speichern' }); } } + +/** + * Admin: Geburtstagsgruß-Marker für einen Kunden zurücksetzen (Debug / Re-Trigger). + */ +export async function resetBirthdayGreeting(req: AuthRequest, res: Response) { + try { + const customerId = parseInt(req.params.customerId); + await birthdayService.resetBirthdayGreeting(customerId); + + await createAuditLog({ + userId: req.user?.userId, + userEmail: req.user?.email || 'unknown', + action: 'UPDATE', + resourceType: 'Customer', + resourceId: customerId.toString(), + resourceLabel: `Geburtstagsgruß-Marker zurückgesetzt`, + endpoint: req.path, + httpMethod: req.method, + ipAddress: req.socket.remoteAddress || 'unknown', + dataSubjectId: customerId, + }); + + res.json({ success: true }); + } catch (error) { + console.error('Fehler beim Zurücksetzen:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen', + }); + } +} + +/** + * Admin: Geburtstagsgruß manuell senden (Email oder Link für WhatsApp/Telegram/Signal). + */ +export async function sendBirthdayGreeting(req: AuthRequest, res: Response) { + try { + const customerId = parseInt(req.params.customerId); + const { channel } = req.body; // 'email', 'whatsapp', 'telegram', 'signal' + + if (!['email', 'whatsapp', 'telegram', 'signal'].includes(channel)) { + return res.status(400).json({ success: false, error: 'Ungültiger Kanal' }); + } + + const data = await birthdayService.getBirthdayGreetingData(customerId); + if (!data) { + return res.status(400).json({ + success: false, + error: 'Kunde hat kein Geburtsdatum hinterlegt', + }); + } + + const { subject, plain, html } = birthdayService.buildBirthdayGreetingText(data, data.age); + + if (channel === 'email') { + if (!data.email) { + return res.status(400).json({ + success: false, + error: 'Kunde hat keine E-Mail-Adresse hinterlegt', + }); + } + + const systemEmail = await getSystemEmailCredentials(); + if (!systemEmail) { + return res.status(400).json({ + success: false, + error: 'Keine System-E-Mail konfiguriert. Bitte in den Email-Provider-Einstellungen hinterlegen.', + }); + } + + const credentials: SmtpCredentials = { + host: systemEmail.smtpServer, + port: systemEmail.smtpPort, + user: systemEmail.emailAddress, + password: systemEmail.password, + encryption: systemEmail.smtpEncryption, + allowSelfSignedCerts: systemEmail.allowSelfSignedCerts, + }; + + const result = await sendEmail(credentials, systemEmail.emailAddress, { + to: data.email, + subject, + html, + }, { + context: 'birthday-greeting', + customerId, + triggeredBy: req.user?.email, + }); + + if (!result.success) { + return res.status(400).json({ + success: false, + error: `E-Mail-Versand fehlgeschlagen: ${result.error}`, + }); + } + } + + await createAuditLog({ + userId: req.user?.userId, + userEmail: req.user?.email || 'unknown', + action: 'CREATE', + resourceType: 'Customer', + resourceId: customerId.toString(), + resourceLabel: `Geburtstagsgruß gesendet (${channel})`, + endpoint: req.path, + httpMethod: req.method, + ipAddress: req.socket.remoteAddress || 'unknown', + dataSubjectId: customerId, + }); + + res.json({ + success: true, + data: { channel, messageText: plain }, + }); + } catch (error) { + console.error('Fehler beim Senden des Geburtstagsgrußes:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Fehler beim Senden', + }); + } +} diff --git a/backend/src/routes/birthday.routes.ts b/backend/src/routes/birthday.routes.ts index 44766565..19b8249f 100644 --- a/backend/src/routes/birthday.routes.ts +++ b/backend/src/routes/birthday.routes.ts @@ -7,6 +7,10 @@ const router = Router(); // Admin: Kommende und vergangene Geburtstage router.get('/upcoming', authenticate, requirePermission('customers:read'), birthdayController.getUpcomingBirthdays); +// Admin: Gruß-Marker zurücksetzen + Gruß senden +router.post('/:customerId/reset', authenticate, requirePermission('customers:update'), birthdayController.resetBirthdayGreeting); +router.post('/:customerId/send', authenticate, requirePermission('customers:update'), birthdayController.sendBirthdayGreeting); + // Portal: eigener Geburtstag-Check router.get('/my-birthday', authenticate, birthdayController.getMyBirthday); router.post('/my-birthday/acknowledge', authenticate, birthdayController.acknowledgeMyBirthday); diff --git a/backend/src/services/birthday.service.ts b/backend/src/services/birthday.service.ts index dd3d7b16..c1815ba1 100644 --- a/backend/src/services/birthday.service.ts +++ b/backend/src/services/birthday.service.ts @@ -214,3 +214,123 @@ export async function acknowledgeBirthdayGreeting(customerId: number): Promise { + await prisma.customer.update({ + where: { id: customerId }, + data: { lastBirthdayGreetingYear: null }, + }); +} + +/** + * Generiert den persönlichen Geburtstagsgruß-Text (Du/Sie-abhängig). + */ +export function buildBirthdayGreetingText( + customer: { + firstName: string; + lastName: string; + salutation: string | null; + useInformalAddress: boolean; + }, + age: number, +): { subject: string; plain: string; html: string } { + const name = customer.useInformalAddress + ? customer.firstName + : [customer.salutation, customer.lastName].filter(Boolean).join(' ') || customer.firstName; + const du = customer.useInformalAddress; + const pronoun = du ? 'dir' : 'Ihnen'; + const possessive = du ? 'deinem' : 'Ihrem'; + const yourLower = du ? 'dein' : 'Ihr'; + + const subject = du + ? `Alles Gute zum Geburtstag, ${customer.firstName}! 🎉` + : 'Herzlichen Glückwunsch zum Geburtstag 🎉'; + + const plain = [ + `Herzlichen Glückwunsch, ${name}!`, + '', + age > 0 + ? `Alles Gute zu ${possessive} ${age}. Geburtstag!` + : `Alles Gute zu ${possessive} Geburtstag!`, + '', + `Wir wünschen ${pronoun} einen wunderschönen Tag und alles Gute für ${yourLower} neues Lebensjahr. 🌟`, + '', + 'Herzliche Grüße', + 'Hacker-Net Telekommunikation', + ].join('\n'); + + const html = ` +
+
+
🎉🎂🎈
+
+
+

Herzlichen Glückwunsch, ${name}!

+

+ ${age > 0 ? `Alles Gute zu ${possessive} ${age}. Geburtstag!` : `Alles Gute zu ${possessive} Geburtstag!`} +

+

+ Wir wünschen ${pronoun} einen wunderschönen Tag und alles Gute für ${yourLower} neues Lebensjahr. 🌟 +

+
+

+ Herzliche Grüße
+ Hacker-Net Telekommunikation
+ Am Wunderburgpark 5b, 26135 Oldenburg
+ info@hacker-net.de +

+
+
+ `; + + return { subject, plain, html }; +} + +/** + * Lädt die für den Gruß benötigten Kundendaten inkl. aktuellem Alter heute. + */ +export async function getBirthdayGreetingData(customerId: number): Promise<{ + firstName: string; + lastName: string; + salutation: string | null; + useInformalAddress: boolean; + email: string | null; + phone: string | null; + mobile: string | null; + age: number; +} | null> { + const c = await prisma.customer.findUnique({ + where: { id: customerId }, + select: { + firstName: true, + lastName: true, + salutation: true, + useInformalAddress: true, + email: true, + phone: true, + mobile: true, + birthDate: true, + }, + }); + + if (!c?.birthDate) return null; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const age = calculateAge(c.birthDate, today); + + return { + firstName: c.firstName, + lastName: c.lastName, + salutation: c.salutation, + useInformalAddress: c.useInformalAddress, + email: c.email, + phone: c.phone, + mobile: c.mobile, + age, + }; +} diff --git a/backend/todo.md b/backend/todo.md index fc3a9a1e..1bbbe7e9 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -57,6 +57,15 @@ als Factory-Default beim Initialisieren wieder einspielen lassen. ## ✅ Erledigt +- [x] **Geburtstag-Management-Modal in Kundenstammdaten** + - Neuer Button (Cake-Icon) neben Geburtsdatum öffnet Modal + - **Gruß zurücksetzen:** setzt `lastBirthdayGreetingYear` auf null zurück (fürs Debugging + Fallback) + - **Gruß jetzt senden:** per Email (direkt), WhatsApp/Telegram/Signal (öffnet vorbefülltes Fenster) + - Beide Aktionen mit Ja/Nein-Bestätigungsdialog (kein versehentliches Klicken) + - Text respektiert Du/Sie-Einstellung des Kunden + - Checkbox "Automatisch senden" mit Kanal-Dropdown (neue Felder am Customer) + - Audit-Log für Reset + Send + - [x] **Anrede-Verhältnis Du/Sie pro Kunde** - Neues Feld `useInformalAddress` in Stammdaten (auch bei Firmenkunden) - Default: Sie (formell) diff --git a/frontend/src/components/BirthdayManagementModal.tsx b/frontend/src/components/BirthdayManagementModal.tsx new file mode 100644 index 00000000..1a2d097d --- /dev/null +++ b/frontend/src/components/BirthdayManagementModal.tsx @@ -0,0 +1,302 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { birthdayApi, customerApi } from '../services/api'; +import Button from './ui/Button'; +import { X, Cake, RotateCcw, Send, AlertTriangle, Loader2, Check } from 'lucide-react'; +import type { Customer } from '../types'; + +interface Props { + customer: Customer; + onClose: () => void; +} + +type Channel = 'email' | 'whatsapp' | 'telegram' | 'signal'; + +const channelLabels: Record = { + email: { label: 'Per E-Mail', icon: '✉️' }, + whatsapp: { label: 'Per WhatsApp', icon: '💬' }, + telegram: { label: 'Per Telegram', icon: '📨' }, + signal: { label: 'Per Signal', icon: '📱' }, +}; + +type ConfirmState = + | { type: 'none' } + | { type: 'reset' } + | { type: 'send'; channel: Channel }; + +export default function BirthdayManagementModal({ customer, onClose }: Props) { + const queryClient = useQueryClient(); + const [confirm, setConfirm] = useState({ type: 'none' }); + const [autoEnabled, setAutoEnabled] = useState(customer.autoBirthdayGreeting ?? false); + const [autoChannel, setAutoChannel] = useState( + (customer.autoBirthdayChannel as Channel) || 'email', + ); + const [saved, setSaved] = useState(false); + const [sentChannel, setSentChannel] = useState(null); + const [reset, setReset] = useState(false); + + const resetMutation = useMutation({ + mutationFn: () => birthdayApi.resetGreeting(customer.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['customer', customer.id] }); + setReset(true); + setTimeout(() => setReset(false), 2500); + }, + }); + + const sendMutation = useMutation({ + mutationFn: (channel: Channel) => birthdayApi.sendGreeting(customer.id, channel), + onSuccess: (result, channel) => { + const text = result.data?.messageText || ''; + if (channel === 'whatsapp') { + window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, '_blank'); + } else if (channel === 'telegram') { + window.open( + `https://t.me/share/url?url=&text=${encodeURIComponent(text)}`, + '_blank', + ); + } else if (channel === 'signal') { + window.open(`signal://send?text=${encodeURIComponent(text)}`, '_blank'); + } + setSentChannel(channel); + setTimeout(() => setSentChannel(null), 2500); + }, + }); + + const saveAutoMutation = useMutation({ + mutationFn: () => + customerApi.update(customer.id, { + autoBirthdayGreeting: autoEnabled, + autoBirthdayChannel: autoEnabled ? autoChannel : null, + } as any), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['customer', customer.id] }); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }, + }); + + const handleConfirmAction = () => { + if (confirm.type === 'reset') { + resetMutation.mutate(); + } else if (confirm.type === 'send') { + sendMutation.mutate(confirm.channel); + } + setConfirm({ type: 'none' }); + }; + + const birthDateDisplay = customer.birthDate + ? new Date(customer.birthDate).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + : '-'; + + return ( +
+
+ {/* Header */} +
+ +
+ +
+

Geburtstag verwalten

+

+ {customer.companyName || `${customer.firstName} ${customer.lastName}`} +

+
+
+
+ + {/* Body */} +
+ {/* Info */} +
+
+ Geburtsdatum: + {birthDateDisplay} +
+ {customer.birthPlace && ( +
+ Geburtsort: + {customer.birthPlace} +
+ )} +
+ Anrede per: + + {customer.useInformalAddress ? 'Du (informell)' : 'Sie (formell)'} + +
+
+ + {/* Gruß zurücksetzen */} +
+

+ + Gruß-Marker zurücksetzen +

+

+ Setzt die Markierung zurück, dass dem Kunden dieses Jahr bereits der Geburtstagsgruß + angezeigt wurde. Beim nächsten Portal-Login erscheint das Modal wieder. +

+ +
+ + {/* Gruß senden */} +
+

+ + Geburtstagsgruß jetzt senden +

+

+ Sendet einen persönlichen Geburtstagsgruß über den gewählten Kanal. +

+
+ {(Object.entries(channelLabels) as [Channel, typeof channelLabels[Channel]][]).map( + ([ch, info]) => ( + + ), + )} +
+ {sendMutation.isError && ( +

+ {(sendMutation.error as any)?.message || 'Fehler beim Senden'} +

+ )} +
+ + {/* Automatisch senden */} +
+

Automatisch senden

+ + {autoEnabled && ( +
+ + +

+ Hinweis: WhatsApp/Telegram/Signal erfordern aktuell einen manuellen Klick im + Browser. Aktuell wird nur automatischer E-Mail-Versand unterstützt. +

+
+ )} + +
+
+
+ + {/* Bestätigungs-Dialog */} + {confirm.type !== 'none' && ( +
+
+
+
+ +
+
+

Bist du sicher?

+

+ {confirm.type === 'reset' + ? 'Möchtest du den Geburtstagsgruß-Marker wirklich zurücksetzen? Beim nächsten Portal-Login sieht der Kunde das Geburtstagsmodal erneut.' + : `Möchtest du den Geburtstagsgruß wirklich ${channelLabels[confirm.channel].label.toLowerCase()} senden?`} +

+
+
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/customers/CustomerDetail.tsx b/frontend/src/pages/customers/CustomerDetail.tsx index 95de82a5..2c9cdeff 100644 --- a/frontend/src/pages/customers/CustomerDetail.tsx +++ b/frontend/src/pages/customers/CustomerDetail.tsx @@ -13,8 +13,9 @@ import Modal from '../../components/ui/Modal'; import Input from '../../components/ui/Input'; import Select from '../../components/ui/Select'; import FileUpload from '../../components/ui/FileUpload'; -import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft } from 'lucide-react'; +import { Edit, Plus, Trash2, MapPin, CreditCard, FileText, Gauge, Eye, EyeOff, Download, Globe, UserPlus, X, Search, Mail, Copy, Check, ChevronDown, ChevronRight, Info, Shield, ShieldCheck, ShieldX, ShieldAlert, Lock, ArrowLeft, Cake } from 'lucide-react'; import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton'; +import BirthdayManagementModal from '../../components/BirthdayManagementModal'; import { formatDate } from '../../utils/dateFormat'; import { getContractTypeInfo } from '../../utils/contractInfo'; import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types'; @@ -42,6 +43,7 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId? const [showDocumentModal, setShowDocumentModal] = useState(false); const [showMeterModal, setShowMeterModal] = useState(false); const [showStressfreiEmailModal, setShowStressfreiEmailModal] = useState(false); + const [showBirthdayModal, setShowBirthdayModal] = useState(false); const [showInactive, setShowInactive] = useState(false); const [editingBankCard, setEditingBankCard] = useState(null); const [editingDocument, setEditingDocument] = useState(null); @@ -313,6 +315,16 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
{new Date(c.birthDate).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} + {!isCustomerPortal && hasPermission('customers:update') && ( + + )}
)} @@ -451,6 +463,13 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId? email={editingStressfreiEmail} customerEmail={customer?.data?.email} /> + + {showBirthdayModal && c && ( + setShowBirthdayModal(false)} + /> + )} ); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ba5beebb..e8018a1c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1620,6 +1620,17 @@ export const birthdayApi = { const res = await api.post>('/birthdays/my-birthday/acknowledge'); return res.data; }, + resetGreeting: async (customerId: number) => { + const res = await api.post>(`/birthdays/${customerId}/reset`); + return res.data; + }, + sendGreeting: async (customerId: number, channel: 'email' | 'whatsapp' | 'telegram' | 'signal') => { + const res = await api.post>( + `/birthdays/${customerId}/send`, + { channel }, + ); + return res.data; + }, }; export default api; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5fa7bfad..993f889b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -63,6 +63,8 @@ export interface Customer { type: 'PRIVATE' | 'BUSINESS'; salutation?: string; useInformalAddress?: boolean; + autoBirthdayGreeting?: boolean; + autoBirthdayChannel?: string | null; firstName: string; lastName: string; companyName?: string;