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:
co-authored by
Claude Sonnet 5
parent
43c82e655b
commit
99b5e07390
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user