Add birthday reminder widget to omsorgapp dashboard (FR-MA-7)

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 <input type="date"> expects — needed for the widget's
birthday calculation and previously left the fields blank on edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-10 20:45:19 +02:00
co-authored by Claude Sonnet 5
parent 43c82e655b
commit 99b5e07390
4 changed files with 126 additions and 3 deletions
+1
View File
@@ -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-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-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-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 ### 4.3 Einrichtungen & CRM
@@ -21,20 +21,31 @@ export const emptyEmployeeForm = {
qualification: "", qualification: "",
}; };
// Der generierte Client wandelt Datumsfelder aus der API-Antwort automatisch in Date-Objekte um
// (siehe employeesApi.js) - <input type="date"> 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) { export function employeeToFormValues(employee) {
return { return {
firstName: employee.firstName ?? "", firstName: employee.firstName ?? "",
lastName: employee.lastName ?? "", lastName: employee.lastName ?? "",
status: employee.status ?? "Aktiv", status: employee.status ?? "Aktiv",
dateOfBirth: employee.dateOfBirth ?? "", dateOfBirth: toDateInputValue(employee.dateOfBirth),
street: employee.street ?? "", street: employee.street ?? "",
postalCode: employee.postalCode ?? "", postalCode: employee.postalCode ?? "",
city: employee.city ?? "", city: employee.city ?? "",
country: employee.country ?? "", country: employee.country ?? "",
phoneNumber: employee.phoneNumber ?? "", phoneNumber: employee.phoneNumber ?? "",
email: employee.email ?? "", email: employee.email ?? "",
entryDate: employee.entryDate ?? "", entryDate: toDateInputValue(employee.entryDate),
exitDate: employee.exitDate ?? "", exitDate: toDateInputValue(employee.exitDate),
emergencyContactName: employee.emergencyContactName ?? "", emergencyContactName: employee.emergencyContactName ?? "",
emergencyContactPhone: employee.emergencyContactPhone ?? "", emergencyContactPhone: employee.emergencyContactPhone ?? "",
emergencyContactRelation: employee.emergencyContactRelation ?? "", emergencyContactRelation: employee.emergencyContactRelation ?? "",
@@ -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 (
<OmsorgCard title="Anstehende Geburtstage">
<div className="contract-widget">
<div className="contract-widget__intro">
<Cake size={20} />
<p>Mitarbeiter, die in den nächsten {REMINDER_DAYS_AHEAD} Tagen Geburtstag haben.</p>
</div>
{!isLoading && upcoming.length === 0 && <p>Keine anstehenden Geburtstage.</p>}
<div className="contract-widget__list">
{upcoming.map(({ employee, days }) => (
<article key={employee.id} className={`contract-item contract-item--${days === 0 ? "danger" : "info"}`}>
<div className="contract-item__header">
<div>
<strong>
{employee.firstName} {employee.lastName}
</strong>
</div>
<strong>{remainingLabel(days)}</strong>
</div>
</article>
))}
</div>
</div>
</OmsorgCard>
);
}
+2
View File
@@ -5,6 +5,7 @@ import HomeStats from "./HomeStats";
import OmsorgBadge from "../../components/ui/OmsorgBadge"; import OmsorgBadge from "../../components/ui/OmsorgBadge";
import FollowUpWidget from "./FollowUpWidget"; import FollowUpWidget from "./FollowUpWidget";
import OrderStatusWidget from "./OrderStatusWidget"; import OrderStatusWidget from "./OrderStatusWidget";
import BirthdayWidget from "./BirthdayWidget";
export default function HomePage() { export default function HomePage() {
return ( return (
<> <>
@@ -31,6 +32,7 @@ export default function HomePage() {
<section className="dashboard-grid home-grid"> <section className="dashboard-grid home-grid">
<FollowUpWidget /> <FollowUpWidget />
<OrderStatusWidget /> <OrderStatusWidget />
<BirthdayWidget />
<OmsorgCard title="Schnellaktionen"> <OmsorgCard title="Schnellaktionen">
<div className="home-actions"> <div className="home-actions">