Geburtstagskalender + Geburtstagsgruß-Modal im Kundenportal

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 11:51:20 +02:00
parent 2775e9d4dc
commit 018784cca6
9 changed files with 467 additions and 1 deletions
+84
View File
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden">
{/* Konfetti-Header */}
<div className="bg-gradient-to-br from-pink-500 via-purple-500 to-indigo-500 p-6 text-center relative">
<button
onClick={handleClose}
className="absolute top-3 right-3 text-white/80 hover:text-white transition-colors"
aria-label="Schließen"
>
<X className="w-5 h-5" />
</button>
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-white/20 backdrop-blur mb-3">
<Cake className="w-10 h-10 text-white" />
</div>
<div className="text-5xl mb-1">🎉🎂🎈</div>
</div>
{/* Nachricht */}
<div className="p-6 text-center">
{info.isToday ? (
<>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Herzlichen Glückwunsch, {info.firstName}!
</h2>
<p className="text-gray-600 mb-1">
Alles Gute zu Ihrem {info.age}. Geburtstag!
</p>
<p className="text-gray-500 text-sm mb-6">
Wir wünschen Ihnen einen wunderschönen Tag und alles Gute für das neue Lebensjahr. 🌟
</p>
</>
) : (
<>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Nachträglich alles Gute, {info.firstName}!
</h2>
<p className="text-gray-600 mb-1">
Sie hatten vor {info.daysAgo} Tag{info.daysAgo === 1 ? '' : 'en'} Geburtstag
{info.age > 0 && ` und sind ${info.age} Jahre alt geworden`}.
</p>
<p className="text-gray-500 text-sm mb-6">
Wir wünschen Ihnen alles Gute nachträglich und eine tolle Zeit im neuen Lebensjahr. 🌟
</p>
</>
)}
<Button onClick={handleClose} disabled={ackMutation.isPending} className="w-full">
Vielen Dank!
</Button>
</div>
</div>
</div>
);
}
@@ -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() {
<PortalFooter />
</div>
<ScrollToTopButton />
<BirthdayModal />
</div>
);
}
@@ -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<number | null>(null);
const [customDate, setCustomDate] = useState('');
@@ -661,6 +669,60 @@ export default function ContractCockpit() {
</Card>
)}
{/* Geburtstage */}
{birthdaysData?.data && birthdaysData.data.length > 0 && (
<Card className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Cake className="w-5 h-5 text-pink-500" />
<h3 className="font-medium">Geburtstage</h3>
<Badge variant="default">{birthdaysData.data.length}</Badge>
</div>
<p className="text-sm text-gray-500 mb-3">
Kunden mit Geburtstag in den nächsten 30 Tagen oder den letzten 7 Tagen.
</p>
<div className="space-y-2">
{birthdaysData.data.map((b) => (
<div
key={b.customerId}
className={`flex items-center justify-between p-3 rounded-lg border ${
b.isToday
? 'bg-pink-50 border-pink-300'
: b.isPast
? 'bg-amber-50 border-amber-200'
: 'bg-gray-50 border-gray-200'
}`}
>
<div className="flex items-center gap-3">
<Cake className={`w-4 h-4 ${b.isToday ? 'text-pink-500' : b.isPast ? 'text-amber-500' : 'text-gray-400'}`} />
<div>
<Link
to={`/customers/${b.customerId}`}
state={pushHistory('/contracts/cockpit')}
className="text-sm font-medium hover:underline"
>
{b.name}
</Link>
<span className="text-xs text-gray-500 ml-2">({b.customerNumber})</span>
<p className="text-xs text-gray-600">
{formatDate(b.birthDate)} wird {b.age} Jahre
</p>
</div>
</div>
<div className="text-right">
{b.isToday ? (
<Badge variant="warning">🎉 Heute!</Badge>
) : b.isPast ? (
<Badge variant="default">Vor {Math.abs(b.daysUntil)} Tag{Math.abs(b.daysUntil) === 1 ? '' : 'en'}</Badge>
) : (
<Badge variant="default">In {b.daysUntil} Tag{b.daysUntil === 1 ? '' : 'en'}</Badge>
)}
</div>
</div>
))}
</div>
</Card>
)}
{/* Filter */}
<Card className="mb-6">
<div className="flex items-center gap-4">
+40
View File
@@ -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<ApiResponse<BirthdayEntry[]>>('/birthdays/upcoming', {
params: { past, future },
});
return res.data;
},
getMyBirthday: async () => {
const res = await api.get<ApiResponse<MyBirthdayCheck | null>>('/birthdays/my-birthday');
return res.data;
},
acknowledgeMyBirthday: async () => {
const res = await api.post<ApiResponse<void>>('/birthdays/my-birthday/acknowledge');
return res.data;
},
};
export default api;