From 99b5e07390423bc0fa40dfbd7061da3f2690a1f9 Mon Sep 17 00:00:00 2001 From: Felix Kemmler Date: Mon, 10 Aug 2026 20:45:19 +0200 Subject: [PATCH] Add birthday reminder widget to omsorgapp dashboard (FR-MA-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fixes date-input fields on the employee form (dateOfBirth/entryDate/ exitDate) to convert the generated client's Date objects into the "YYYY-MM-DD" string expects — needed for the widget's birthday calculation and previously left the fields blank on edit. Co-Authored-By: Claude Sonnet 5 --- REQUIREMENTS.md | 1 + .../src/modules/employees/EmployeeForm.jsx | 17 ++- omsorgapp/src/modules/home/BirthdayWidget.jsx | 109 ++++++++++++++++++ omsorgapp/src/modules/home/HomePage.jsx | 2 + 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 omsorgapp/src/modules/home/BirthdayWidget.jsx diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index c572325..41d85b5 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -80,6 +80,7 @@ Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backe | FR-MA-4 | Eintrittsdatum löst Dashboard-Hinweis auf bevorstehenden Mitarbeiterstart aus [Blueprint 7] | alle Büro-Rollen | X Tage vor Eintritt erscheint Engine-Hinweis im Dashboard | ⬜ (abhängig von Engine, siehe 4.10) | | FR-MA-5 | Bei Büromitarbeitern kann der Mitarbeiterdatensatz mit einem Benutzerkonto + individuellen Rechten verknüpft werden [Blueprint 19.1] | Sabina, Malik | Rechteliste pro Modul (sehen/anlegen/bearbeiten/löschen/exportieren/freigeben) editierbar | ✅ (in `omsorgCore`, nicht mehr `omsorgWeb`: `User.EmployeeId` verknüpft ein Benutzerkonto mit dem `Employee`-Datensatz, `UsersController.Create`/`UserService.CreateForEmployeeAsync` erzwingt genau 1 Konto je Mitarbeiter; Rechte sind granular pro Modul × Aktion — Rolle als Vorlage (`RolePermission`) plus individuelle Grant/Revoke-Ausnahmen je Nutzer (`UserPermissionOverride`), inkl. `Recover` seit dem Soft-Delete-Schritt; Admin-UI in `omsorgapp`: `RolesPanel`/`RolePermissionMatrix`/`UserOverridesPanel` unter "Einstellungen") | | FR-MA-6 | Außendienstmitarbeiter sehen ausschließlich eigene Daten [Blueprint 4.2] | Außendienst | Query/Route liefert nur Datensätze mit `user_id = aktueller Nutzer` | ✅ (Session-basierter Zugriff in `omsorgWeb/mitarbeiter-app`, z. B. `pages/urlaubsantrag.php`) | +| FR-MA-7 | Geburtstags-Erinnerung im Dashboard: 5 Tage vor dem Geburtstag eines aktiven Mitarbeiters erscheint ein Hinweis | alle Büro-Rollen mit Mitarbeiter-Leserecht | Widget zeigt Mitarbeiter mit Geburtstag in den nächsten 5 Tagen, verschwindet danach automatisch wieder | ⬜ | ### 4.3 Einrichtungen & CRM diff --git a/omsorgapp/src/modules/employees/EmployeeForm.jsx b/omsorgapp/src/modules/employees/EmployeeForm.jsx index 56f5e33..b6d5496 100644 --- a/omsorgapp/src/modules/employees/EmployeeForm.jsx +++ b/omsorgapp/src/modules/employees/EmployeeForm.jsx @@ -21,20 +21,31 @@ export const emptyEmployeeForm = { qualification: "", }; +// Der generierte Client wandelt Datumsfelder aus der API-Antwort automatisch in Date-Objekte um +// (siehe employeesApi.js) - braucht dafür einen "YYYY-MM-DD"-String, sonst +// bleibt das Feld leer (Muster analog AbsenceForm.jsx/OrderForm.jsx/TimeEntryForm.jsx). +function toDateInputValue(value) { + if (!value) { + return ""; + } + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10); +} + export function employeeToFormValues(employee) { return { firstName: employee.firstName ?? "", lastName: employee.lastName ?? "", status: employee.status ?? "Aktiv", - dateOfBirth: employee.dateOfBirth ?? "", + dateOfBirth: toDateInputValue(employee.dateOfBirth), street: employee.street ?? "", postalCode: employee.postalCode ?? "", city: employee.city ?? "", country: employee.country ?? "", phoneNumber: employee.phoneNumber ?? "", email: employee.email ?? "", - entryDate: employee.entryDate ?? "", - exitDate: employee.exitDate ?? "", + entryDate: toDateInputValue(employee.entryDate), + exitDate: toDateInputValue(employee.exitDate), emergencyContactName: employee.emergencyContactName ?? "", emergencyContactPhone: employee.emergencyContactPhone ?? "", emergencyContactRelation: employee.emergencyContactRelation ?? "", diff --git a/omsorgapp/src/modules/home/BirthdayWidget.jsx b/omsorgapp/src/modules/home/BirthdayWidget.jsx new file mode 100644 index 0000000..ecbcaef --- /dev/null +++ b/omsorgapp/src/modules/home/BirthdayWidget.jsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; +import { Cake } from "lucide-react"; + +import OmsorgCard from "../../components/OmsorgCard"; +import { useAuth } from "../../app/AuthContext"; + +const WIDGET_PAGE_SIZE = 200; +const REMINDER_DAYS_AHEAD = 5; + +// Nächstes Vorkommen des Geburtstags (dieses Jahr, oder nächstes Jahr falls schon vorbei) - Alter +// spielt für die Erinnerung keine Rolle, nur der Tag/Monat. +function daysUntilNextBirthday(dateOfBirth) { + const birth = dateOfBirth instanceof Date ? dateOfBirth : new Date(dateOfBirth); + if (Number.isNaN(birth.getTime())) { + return null; + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + let next = new Date(today.getFullYear(), birth.getMonth(), birth.getDate()); + next.setHours(0, 0, 0, 0); + if (next < today) { + next = new Date(today.getFullYear() + 1, birth.getMonth(), birth.getDate()); + } + + return Math.round((next - today) / (1000 * 60 * 60 * 24)); +} + +function remainingLabel(days) { + if (days === 0) { + return "heute"; + } + if (days === 1) { + return "morgen"; + } + return `in ${days} Tagen`; +} + +export default function BirthdayWidget() { + const { hasPermission } = useAuth(); + const canView = hasPermission("Employees", "View"); + + const [employees, setEmployees] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!canView) { + return undefined; + } + + let cancelled = false; + + async function load() { + setIsLoading(true); + const result = await window.omsorg.employees.list({ status: "Aktiv", page: 1, pageSize: WIDGET_PAGE_SIZE }); + + if (!cancelled) { + setEmployees(result.ok ? result.data?.items ?? [] : []); + setIsLoading(false); + } + } + + load(); + return () => { + cancelled = true; + }; + }, [canView]); + + if (!canView) { + return null; + } + + const upcoming = employees + .filter((employee) => employee.dateOfBirth) + .map((employee) => ({ employee, days: daysUntilNextBirthday(employee.dateOfBirth) })) + .filter(({ days }) => days !== null && days >= 0 && days <= REMINDER_DAYS_AHEAD) + .sort((a, b) => a.days - b.days); + + return ( + +
+
+ + +

Mitarbeiter, die in den nächsten {REMINDER_DAYS_AHEAD} Tagen Geburtstag haben.

+
+ + {!isLoading && upcoming.length === 0 &&

Keine anstehenden Geburtstage.

} + +
+ {upcoming.map(({ employee, days }) => ( +
+
+
+ + {employee.firstName} {employee.lastName} + +
+ + {remainingLabel(days)} +
+
+ ))} +
+
+
+ ); +} diff --git a/omsorgapp/src/modules/home/HomePage.jsx b/omsorgapp/src/modules/home/HomePage.jsx index 63b1563..42b91a0 100644 --- a/omsorgapp/src/modules/home/HomePage.jsx +++ b/omsorgapp/src/modules/home/HomePage.jsx @@ -5,6 +5,7 @@ import HomeStats from "./HomeStats"; import OmsorgBadge from "../../components/ui/OmsorgBadge"; import FollowUpWidget from "./FollowUpWidget"; import OrderStatusWidget from "./OrderStatusWidget"; +import BirthdayWidget from "./BirthdayWidget"; export default function HomePage() { return ( <> @@ -31,6 +32,7 @@ export default function HomePage() {
+