From 9e55e25dc8fcec30072533a3c2580788aa840cd2 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 23 Apr 2026 11:51:20 +0200 Subject: [PATCH] =?UTF-8?q?Geburtstagskalender=20+=20Geburtstagsgru=C3=9F-?= =?UTF-8?q?Modal=20im=20Kundenportal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin (Vertrags-Cockpit): - Neue Section "Geburtstage" zeigt Kunden mit Geburtstag - Fenster: -7 bis +30 Tage um heute - Farbcodierung: heute (pink), vergangen (amber), bevorstehend (grau) - Anzeige: Name, Kundennummer, Geburtsdatum, Alter, "Heute!" / "In X Tagen" / "Vor X Tagen" Portal (Kundenportal): - Modal mit Geburtstagsgruß wenn Geburtstag heute oder in den letzten 7 Tagen war - Unterscheidet zwischen aktuellem Geburtstag und nachträglichen Glückwünschen - Schönes Gradient-Design mit Konfetti-Emojis - Wird pro Jahr nur einmal angezeigt (Customer.lastBirthdayGreetingYear) - Bestätigung speichert das aktuelle Jahr Backend: - Neues Feld Customer.lastBirthdayGreetingYear (Int?) - Service birthday.service.ts mit Fenster-Logik + Alter-Berechnung - Endpoints /api/birthdays/upcoming (Admin), /api/birthdays/my-birthday (Portal GET + POST /acknowledge) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/prisma/schema.prisma | 3 + .../src/controllers/birthday.controller.ts | 56 +++++ backend/src/index.ts | 2 + backend/src/routes/birthday.routes.ts | 14 ++ backend/src/services/birthday.service.ts | 203 ++++++++++++++++++ frontend/src/components/BirthdayModal.tsx | 84 ++++++++ frontend/src/components/layout/Layout.tsx | 2 + .../src/pages/contracts/ContractCockpit.tsx | 64 +++++- frontend/src/services/api.ts | 40 ++++ 9 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 backend/src/controllers/birthday.controller.ts create mode 100644 backend/src/routes/birthday.routes.ts create mode 100644 backend/src/services/birthday.service.ts create mode 100644 frontend/src/components/BirthdayModal.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a6598c13..89de0d66 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -163,6 +163,9 @@ model Customer { portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige) portalLastLogin DateTime? // Letzte Anmeldung + // Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen) + lastBirthdayGreetingYear Int? + user User? addresses Address[] bankCards BankCard[] diff --git a/backend/src/controllers/birthday.controller.ts b/backend/src/controllers/birthday.controller.ts new file mode 100644 index 00000000..f26b3da5 --- /dev/null +++ b/backend/src/controllers/birthday.controller.ts @@ -0,0 +1,56 @@ +import { Response } from 'express'; +import { AuthRequest } from '../types/index.js'; +import * as birthdayService from '../services/birthday.service.js'; + +/** + * Admin/Mitarbeiter: Kommende und vergangene Geburtstage + * Query: ?past=7&future=30 (Default) + */ +export async function getUpcomingBirthdays(req: AuthRequest, res: Response) { + try { + const past = req.query.past ? parseInt(String(req.query.past)) : 7; + const future = req.query.future ? parseInt(String(req.query.future)) : 30; + + const entries = await birthdayService.getUpcomingBirthdays(past, future); + res.json({ success: true, data: entries }); + } catch (error) { + console.error('Fehler beim Abrufen der Geburtstage:', error); + res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Geburtstage' }); + } +} + +/** + * Portal: Eigenen Geburtstags-Check – soll das Modal angezeigt werden? + */ +export async function getMyBirthday(req: AuthRequest, res: Response) { + try { + const user = req.user as any; + if (!user?.isCustomerPortal || !user?.customerId) { + return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' }); + } + + const data = await birthdayService.checkMyBirthday(user.customerId); + res.json({ success: true, data }); + } catch (error) { + console.error('Fehler beim Geburtstags-Check:', error); + res.status(500).json({ success: false, error: 'Fehler beim Abruf' }); + } +} + +/** + * Portal: Modal als gesehen markieren + */ +export async function acknowledgeMyBirthday(req: AuthRequest, res: Response) { + try { + const user = req.user as any; + if (!user?.isCustomerPortal || !user?.customerId) { + return res.status(403).json({ success: false, error: 'Nur für Kundenportal-Benutzer' }); + } + + await birthdayService.acknowledgeBirthdayGreeting(user.customerId); + res.json({ success: true }); + } catch (error) { + console.error('Fehler beim Bestätigen:', error); + res.status(500).json({ success: false, error: 'Fehler beim Speichern' }); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 7a4a741c..59eea162 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -31,6 +31,7 @@ import gdprRoutes from './routes/gdpr.routes.js'; import consentPublicRoutes from './routes/consent-public.routes.js'; import emailLogRoutes from './routes/emailLog.routes.js'; import pdfTemplateRoutes from './routes/pdfTemplate.routes.js'; +import birthdayRoutes from './routes/birthday.routes.js'; import { auditContextMiddleware } from './middleware/auditContext.js'; import { auditMiddleware } from './middleware/audit.js'; @@ -81,6 +82,7 @@ app.use('/api/audit-logs', auditLogRoutes); app.use('/api/gdpr', gdprRoutes); app.use('/api/email-logs', emailLogRoutes); app.use('/api/pdf-templates', pdfTemplateRoutes); +app.use('/api/birthdays', birthdayRoutes); // Health check app.get('/api/health', (req, res) => { diff --git a/backend/src/routes/birthday.routes.ts b/backend/src/routes/birthday.routes.ts new file mode 100644 index 00000000..44766565 --- /dev/null +++ b/backend/src/routes/birthday.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import * as birthdayController from '../controllers/birthday.controller.js'; +import { authenticate, requirePermission } from '../middleware/auth.js'; + +const router = Router(); + +// Admin: Kommende und vergangene Geburtstage +router.get('/upcoming', authenticate, requirePermission('customers:read'), birthdayController.getUpcomingBirthdays); + +// Portal: eigener Geburtstag-Check +router.get('/my-birthday', authenticate, birthdayController.getMyBirthday); +router.post('/my-birthday/acknowledge', authenticate, birthdayController.acknowledgeMyBirthday); + +export default router; diff --git a/backend/src/services/birthday.service.ts b/backend/src/services/birthday.service.ts new file mode 100644 index 00000000..3430475c --- /dev/null +++ b/backend/src/services/birthday.service.ts @@ -0,0 +1,203 @@ +import prisma from '../lib/prisma.js'; + +export interface BirthdayEntry { + customerId: number; + customerNumber: string; + name: string; + birthDate: Date; + age: number; // Alter das der Kunde wird bzw. geworden ist + daysUntil: number; // 0 = heute, -3 = vor 3 Tagen, +5 = in 5 Tagen + isToday: boolean; + isPast: boolean; // Geburtstag war in den letzten Tagen + portalEnabled: boolean; + email?: string | null; + phone?: string | null; +} + +/** + * Berechnet die Anzahl Tage von "heute" bis zum nächsten Vorkommen eines Geburtstags. + * Negative Werte = Geburtstag war kürzlich, positive = Geburtstag steht bevor. + * + * Für den Zeitraum `[-pastDays, +futureDays]` suchen wir den relevantesten Wert: + * - ist der Geburtstag dieses Jahr innerhalb des Fensters → dieser + * - sonst: nichts (nicht relevant) + */ +function daysDifferenceInWindow( + birthDate: Date, + today: Date, + pastDays: number, + futureDays: number, +): number | null { + const month = birthDate.getMonth(); + const day = birthDate.getDate(); + + // Geburtstag dieses Jahr + const thisYear = new Date(today.getFullYear(), month, day); + thisYear.setHours(0, 0, 0, 0); + + const todayNormalized = new Date(today); + todayNormalized.setHours(0, 0, 0, 0); + + const msPerDay = 1000 * 60 * 60 * 24; + const diffThisYear = Math.round((thisYear.getTime() - todayNormalized.getTime()) / msPerDay); + + // Falls im Fenster → nutzen + if (diffThisYear >= -pastDays && diffThisYear <= futureDays) { + return diffThisYear; + } + + // Falls Geburtstag schon lange vorbei (>pastDays her), ist nächstes Jahr relevant + if (diffThisYear < -pastDays) { + const nextYear = new Date(today.getFullYear() + 1, month, day); + nextYear.setHours(0, 0, 0, 0); + const diffNextYear = Math.round((nextYear.getTime() - todayNormalized.getTime()) / msPerDay); + if (diffNextYear <= futureDays) { + return diffNextYear; + } + } + + return null; +} + +function calculateAge(birthDate: Date, refDate: Date): number { + let age = refDate.getFullYear() - birthDate.getFullYear(); + const m = refDate.getMonth() - birthDate.getMonth(); + if (m < 0 || (m === 0 && refDate.getDate() < birthDate.getDate())) { + age--; + } + return age; +} + +/** + * Kommende und vergangene Geburtstage in einem Fenster von [pastDays, futureDays]. + * Default: 7 Tage zurück, 30 Tage voraus. + */ +export async function getUpcomingBirthdays( + pastDays: number = 7, + futureDays: number = 30, +): Promise { + const customers = await prisma.customer.findMany({ + where: { + birthDate: { not: null }, + }, + select: { + id: true, + customerNumber: true, + firstName: true, + lastName: true, + companyName: true, + birthDate: true, + portalEnabled: true, + email: true, + phone: true, + }, + }); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const entries: BirthdayEntry[] = []; + for (const c of customers) { + if (!c.birthDate) continue; + const daysUntil = daysDifferenceInWindow(c.birthDate, today, pastDays, futureDays); + if (daysUntil === null) continue; + + // Alter am nächsten Geburtstag (bei daysUntil >= 0) bzw. aktuelles Alter (daysUntil < 0) + const refDate = new Date(today); + refDate.setDate(refDate.getDate() + daysUntil); + const age = calculateAge(c.birthDate, refDate); + + entries.push({ + customerId: c.id, + customerNumber: c.customerNumber, + name: c.companyName || `${c.firstName} ${c.lastName}`.trim(), + birthDate: c.birthDate, + age, + daysUntil, + isToday: daysUntil === 0, + isPast: daysUntil < 0, + portalEnabled: c.portalEnabled, + email: c.email, + phone: c.phone, + }); + } + + // Sortiert: zuerst heute, dann kommende aufsteigend, dann vergangene absteigend + entries.sort((a, b) => { + if (a.daysUntil === 0 && b.daysUntil !== 0) return -1; + if (b.daysUntil === 0 && a.daysUntil !== 0) return 1; + if (a.daysUntil >= 0 && b.daysUntil >= 0) return a.daysUntil - b.daysUntil; + if (a.daysUntil < 0 && b.daysUntil < 0) return b.daysUntil - a.daysUntil; + // kommende vor vergangenen + return a.daysUntil >= 0 ? -1 : 1; + }); + + return entries; +} + +export interface MyBirthdayCheck { + show: boolean; // Modal anzeigen? + isToday: boolean; + daysAgo: number; // 0 = heute, >0 = x Tage her + firstName: string; + age: number; +} + +/** + * Portal-Check: Soll dem Kunden ein Geburtstagsmodal angezeigt werden? + * - Heute oder in den letzten 7 Tagen + * - Modal dieses Jahr noch nicht gezeigt (lastBirthdayGreetingYear != aktuelles Jahr) + */ +export async function checkMyBirthday(customerId: number): Promise { + const customer = await prisma.customer.findUnique({ + where: { id: customerId }, + select: { + firstName: true, + birthDate: true, + lastBirthdayGreetingYear: true, + }, + }); + + if (!customer?.birthDate) return null; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const thisYear = today.getFullYear(); + + // Schon dieses Jahr angezeigt? + if (customer.lastBirthdayGreetingYear === thisYear) { + return { show: false, isToday: false, daysAgo: 0, firstName: customer.firstName, age: 0 }; + } + + const birthday = new Date(thisYear, customer.birthDate.getMonth(), customer.birthDate.getDate()); + birthday.setHours(0, 0, 0, 0); + + const msPerDay = 1000 * 60 * 60 * 24; + const diff = Math.round((today.getTime() - birthday.getTime()) / msPerDay); + + // Nur wenn heute oder in den letzten 7 Tagen (diff: 0–7) + if (diff < 0 || diff > 7) { + return { show: false, isToday: false, daysAgo: 0, firstName: customer.firstName, age: 0 }; + } + + const age = calculateAge(customer.birthDate, today); + + return { + show: true, + isToday: diff === 0, + daysAgo: diff, + firstName: customer.firstName, + age, + }; +} + +/** + * Markiert, dass dem Kunden das Geburtstagsmodal dieses Jahr bereits gezeigt wurde. + */ +export async function acknowledgeBirthdayGreeting(customerId: number): Promise { + const thisYear = new Date().getFullYear(); + await prisma.customer.update({ + where: { id: customerId }, + data: { lastBirthdayGreetingYear: thisYear }, + }); +} diff --git a/frontend/src/components/BirthdayModal.tsx b/frontend/src/components/BirthdayModal.tsx new file mode 100644 index 00000000..215998fe --- /dev/null +++ b/frontend/src/components/BirthdayModal.tsx @@ -0,0 +1,84 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAuth } from '../context/AuthContext'; +import { birthdayApi } from '../services/api'; +import { Cake, X } from 'lucide-react'; +import Button from './ui/Button'; + +export default function BirthdayModal() { + const { isCustomerPortal } = useAuth(); + const queryClient = useQueryClient(); + + const { data } = useQuery({ + queryKey: ['my-birthday'], + queryFn: () => birthdayApi.getMyBirthday(), + enabled: isCustomerPortal, + staleTime: 60 * 60_000, // 1h (reicht völlig) + }); + + const ackMutation = useMutation({ + mutationFn: () => birthdayApi.acknowledgeMyBirthday(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['my-birthday'] }); + }, + }); + + const info = data?.data; + if (!info?.show) return null; + + const handleClose = () => ackMutation.mutate(); + + return ( +
+
+ {/* Konfetti-Header */} +
+ +
+ +
+
🎉🎂🎈
+
+ + {/* Nachricht */} +
+ {info.isToday ? ( + <> +

+ Herzlichen Glückwunsch, {info.firstName}! +

+

+ Alles Gute zu Ihrem {info.age}. Geburtstag! +

+

+ Wir wünschen Ihnen einen wunderschönen Tag und alles Gute für das neue Lebensjahr. 🌟 +

+ + ) : ( + <> +

+ Nachträglich alles Gute, {info.firstName}! +

+

+ Sie hatten vor {info.daysAgo} Tag{info.daysAgo === 1 ? '' : 'en'} Geburtstag + {info.age > 0 && ` und sind ${info.age} Jahre alt geworden`}. +

+

+ Wir wünschen Ihnen alles Gute nachträglich und eine tolle Zeit im neuen Lebensjahr. 🌟 +

+ + )} + + +
+
+
+ ); +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index 0d33087e..aecf16df 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -4,6 +4,7 @@ import { useAuth } from '../../context/AuthContext'; import { gdprApi } from '../../services/api'; import Sidebar from './Sidebar'; import ScrollToTopButton from '../ScrollToTopButton'; +import BirthdayModal from '../BirthdayModal'; import { AlertTriangle, ArrowRight, Building, Shield } from 'lucide-react'; function ConsentBanner() { @@ -74,6 +75,7 @@ export default function Layout() { + ); } diff --git a/frontend/src/pages/contracts/ContractCockpit.tsx b/frontend/src/pages/contracts/ContractCockpit.tsx index e6475d23..a23d0f4e 100644 --- a/frontend/src/pages/contracts/ContractCockpit.tsx +++ b/frontend/src/pages/contracts/ContractCockpit.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link, useSearchParams } from 'react-router-dom'; import { pushHistory } from '../../utils/navigation'; -import { contractApi, meterApi } from '../../services/api'; +import { contractApi, meterApi, birthdayApi } from '../../services/api'; import Card from '../../components/ui/Card'; import Badge from '../../components/ui/Badge'; import Select from '../../components/ui/Select'; @@ -35,6 +35,7 @@ import { Gauge, ExternalLink, CheckCircle2, + Cake, } from 'lucide-react'; import { formatDate } from '../../utils/dateFormat'; import type { CockpitContract, CockpitUrgencyLevel, ContractType } from '../../types'; @@ -137,6 +138,13 @@ export default function ContractCockpit() { staleTime: 0, }); + // Geburtstage (kommende + vergangene der letzten 7 Tage) + const { data: birthdaysData } = useQuery({ + queryKey: ['upcoming-birthdays'], + queryFn: () => birthdayApi.getUpcoming(7, 30), + staleTime: 5 * 60_000, + }); + const queryClient = useQueryClient(); const [snoozeContractId, setSnoozeContractId] = useState(null); const [customDate, setCustomDate] = useState(''); @@ -661,6 +669,60 @@ export default function ContractCockpit() { )} + {/* Geburtstage */} + {birthdaysData?.data && birthdaysData.data.length > 0 && ( + +
+ +

Geburtstage

+ {birthdaysData.data.length} +
+

+ Kunden mit Geburtstag in den nächsten 30 Tagen oder den letzten 7 Tagen. +

+
+ {birthdaysData.data.map((b) => ( +
+
+ +
+ + {b.name} + + ({b.customerNumber}) +

+ {formatDate(b.birthDate)} – wird {b.age} Jahre +

+
+
+
+ {b.isToday ? ( + 🎉 Heute! + ) : b.isPast ? ( + Vor {Math.abs(b.daysUntil)} Tag{Math.abs(b.daysUntil) === 1 ? '' : 'en'} + ) : ( + In {b.daysUntil} Tag{b.daysUntil === 1 ? '' : 'en'} + )} +
+
+ ))} +
+
+ )} + {/* Filter */}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index dbe9c8f6..e2f47c1c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1579,4 +1579,44 @@ export const publicApi = { getConsentPdfUrl: (hash: string) => `/api/public/consent/${hash}/pdf`, }; +// ============ BIRTHDAY API ============ +export interface BirthdayEntry { + customerId: number; + customerNumber: string; + name: string; + birthDate: string; + age: number; + daysUntil: number; + isToday: boolean; + isPast: boolean; + portalEnabled: boolean; + email?: string | null; + phone?: string | null; +} + +export interface MyBirthdayCheck { + show: boolean; + isToday: boolean; + daysAgo: number; + firstName: string; + age: number; +} + +export const birthdayApi = { + getUpcoming: async (past: number = 7, future: number = 30) => { + const res = await api.get>('/birthdays/upcoming', { + params: { past, future }, + }); + return res.data; + }, + getMyBirthday: async () => { + const res = await api.get>('/birthdays/my-birthday'); + return res.data; + }, + acknowledgeMyBirthday: async () => { + const res = await api.post>('/birthdays/my-birthday/acknowledge'); + return res.data; + }, +}; + export default api;