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:
@@ -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<BirthdayEntry[]> {
|
||||
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<MyBirthdayCheck | null> {
|
||||
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<void> {
|
||||
const thisYear = new Date().getFullYear();
|
||||
await prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: { lastBirthdayGreetingYear: thisYear },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user