Compare commits
11 Commits
88f9c156fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cd16773c1 | |||
| 74dc2d6b57 | |||
| 27c5a6940a | |||
| f28956194c | |||
| e9595fab21 | |||
| f6acb3d271 | |||
| 339563bf4d | |||
| 9915c8213e | |||
| 7eaa198a29 | |||
| cf3fb82afe | |||
| 5dca7c76f8 |
+18
-18
@@ -6,66 +6,66 @@ Geprüft lokal als Admin mit Demo-Login `admin@waldameisen.local`.
|
|||||||
|
|
||||||
## Notdienst-Planung
|
## Notdienst-Planung
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/notdienst/plan/page.tsx:186`
|
- [x] `src/app/dashboard/notdienst/plan/page.tsx:186`
|
||||||
Monat zurück/vor haben keinen Handler oder Link.
|
Monat zurück/vor haben keinen Handler oder Link.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/notdienst/plan/page.tsx:196`
|
- [x] `src/app/dashboard/notdienst/plan/page.tsx:196`
|
||||||
`Einstellungen` ist klickbar, öffnet aber nichts und navigiert nicht.
|
`Einstellungen` ist klickbar, öffnet aber nichts und navigiert nicht.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/notdienst/plan/plan-view.tsx:135`
|
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:135`
|
||||||
`Erinnerungen verschicken` im Empty-State ist ohne Funktion.
|
`Erinnerungen verschicken` im Empty-State ist ohne Funktion.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/notdienst/plan/plan-view.tsx:202`
|
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:202`
|
||||||
`Erinnerung an offene senden (...)` ist ohne Funktion.
|
`Erinnerung an offene senden (...)` ist ohne Funktion.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/notdienst/plan/plan-view.tsx:312`
|
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:312`
|
||||||
`Als PDF exportieren` ist ohne Funktion.
|
`Als PDF exportieren` ist ohne Funktion.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/notdienst/plan/plan-view.tsx:316`
|
- [x] `src/app/dashboard/notdienst/plan/plan-view.tsx:316`
|
||||||
`Plan zurücksetzen` ist ohne Funktion.
|
`Plan zurücksetzen` ist ohne Funktion.
|
||||||
|
|
||||||
## Terminkalender
|
## Terminkalender
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/kalender/page.tsx:296`
|
- [x] `src/app/dashboard/kalender/page.tsx:296`
|
||||||
Umschalter `Liste / Monat / Agenda` ändern keine Ansicht.
|
Umschalter `Liste / Monat / Agenda` ändern keine Ansicht.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/kalender/page.tsx:317`
|
- [x] `src/app/dashboard/kalender/page.tsx:317`
|
||||||
Suche `Termine suchen` filtert aktuell nicht.
|
Suche `Termine suchen` filtert aktuell nicht.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/kalender/page.tsx:415`
|
- [x] `src/app/dashboard/kalender/page.tsx:415`
|
||||||
Kategorie-Filterchips sind nur visuell.
|
Kategorie-Filterchips sind nur visuell.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/kalender/_components/termin-list.tsx:152`
|
- [x] `src/app/dashboard/kalender/_components/termin-list.tsx:152`
|
||||||
`Mehr Optionen` bei Elterndiensten ist ohne Funktion.
|
`Mehr Optionen` bei Elterndiensten ist ohne Funktion.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/kalender/_components/termin-list.tsx:191`
|
- [x] `src/app/dashboard/kalender/_components/termin-list.tsx:191`
|
||||||
`Mehr Optionen` bei Terminen ist ohne Funktion.
|
`Mehr Optionen` bei Terminen ist ohne Funktion.
|
||||||
|
|
||||||
## Adressbuch
|
## Adressbuch
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/adressbuch/page.tsx:161`
|
- [x] `src/app/dashboard/adressbuch/page.tsx:161`
|
||||||
`Sichtbarkeit` ist ohne Funktion.
|
`Sichtbarkeit` ist ohne Funktion.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/adressbuch/page.tsx:193`
|
- [x] `src/app/dashboard/adressbuch/page.tsx:193`
|
||||||
Suche `Familien, Namen oder E-Mail suchen` filtert aktuell nicht.
|
Suche `Familien, Namen oder E-Mail suchen` filtert aktuell nicht.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/adressbuch/page.tsx:201`
|
- [x] `src/app/dashboard/adressbuch/page.tsx:201`
|
||||||
Filterchips `Alle Gruppen`, `Mit Telefon`, `Mit Ämtern`, `Nur Opt-in` sind nur visuell.
|
Filterchips `Alle Gruppen`, `Mit Telefon`, `Mit Ämtern`, `Nur Opt-in` sind nur visuell.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/adressbuch/page.tsx:334`
|
- [x] `src/app/dashboard/adressbuch/page.tsx:334`
|
||||||
`Kontakt öffnen` ist ohne Funktion.
|
`Kontakt öffnen` ist ohne Funktion.
|
||||||
|
|
||||||
## Familienverwaltung
|
## Familienverwaltung
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/families/page.tsx:117`
|
- [x] `src/app/dashboard/families/page.tsx:117`
|
||||||
`Ansicht` ist ohne Funktion.
|
`Ansicht` ist ohne Funktion.
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/families/page.tsx:133`
|
- [x] `src/app/dashboard/families/page.tsx:133`
|
||||||
Suche `Familien, Eltern oder Kinder suchen` filtert aktuell nicht.
|
Suche `Familien, Eltern oder Kinder suchen` filtert aktuell nicht.
|
||||||
|
|
||||||
## Abwesenheiten Admin
|
## Abwesenheiten Admin
|
||||||
|
|
||||||
- [ ] `src/app/dashboard/admin/abwesenheiten/page.tsx:132`
|
- [x] `src/app/dashboard/admin/abwesenheiten/page.tsx:132`
|
||||||
Filterchips `Alle Gruppen` und `Alle Gründe` sind klickbar, aber ohne Funktion.
|
Filterchips `Alle Gruppen` und `Alle Gründe` sind klickbar, aber ohne Funktion.
|
||||||
|
|
||||||
## Bereits angebunden / nicht auffaellig
|
## Bereits angebunden / nicht auffaellig
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Live-Testplan mein-kitaplaner.de
|
||||||
|
|
||||||
|
## Kopfbereich
|
||||||
|
|
||||||
|
- Zielsystem: `https://mein-kitaplaner.de`
|
||||||
|
- Testdatum: `2026-05-17`
|
||||||
|
- Strategie: echte Testdaten auf Produktion verwenden und anschließend bereinigen
|
||||||
|
- Testkennzeichnung: alle Testnamen und Test-E-Mails mit `Codex Live-Test` kennzeichnen
|
||||||
|
- Statuslegende:
|
||||||
|
- `[ ]` offen
|
||||||
|
- `[x]` erledigt
|
||||||
|
- `[!]` Blocker / Beobachtung
|
||||||
|
|
||||||
|
## Testdaten
|
||||||
|
|
||||||
|
- Gründer/Admin: `hanivan790@acanok.com`
|
||||||
|
- Test-Kita: `Codex Live-Test Kita-Verein 20260517-2343`
|
||||||
|
- Elternteil: `kitatester1233@mailaddress.de`
|
||||||
|
- Passwort: `<TEST_PASSWORD>`
|
||||||
|
|
||||||
|
Keine echten Passwörter, Secrets, Session-Werte, Tokens oder Invite-Links in diese Datei schreiben.
|
||||||
|
|
||||||
|
## Core Journey
|
||||||
|
|
||||||
|
- [x] Landingpage erreichbar.
|
||||||
|
- Erwartung: `https://mein-kitaplaner.de` lädt mit aktuellem Landingpage-Content.
|
||||||
|
- [x] Demo-CTA führt zum Kontaktformular.
|
||||||
|
- Erwartung: CTA öffnet `/#kontakt` und das Formular ist sichtbar.
|
||||||
|
- [x] Kontaktformular versendet Bestätigungsmail.
|
||||||
|
- Bestätigt: Testmail an `nolip18234@acanok.com` ist angekommen.
|
||||||
|
- [x] Registrierung: neuer Gründer/Admin kann Account erstellen.
|
||||||
|
- Testdaten: eindeutige Admin-Testmail und `<TEST_PASSWORD>`.
|
||||||
|
- Erwartung: Account wird erstellt, keine Fehlermeldung.
|
||||||
|
- [x] Redirect nach Registrierung führt zu `/onboarding`.
|
||||||
|
- Erwartung: Gründer landet nach Registrierung direkt im Onboarding.
|
||||||
|
- [x] Onboarding: Kita wird mit Standardmodulen angelegt.
|
||||||
|
- Testdaten: `Codex Live-Test Kita-Verein YYYYMMDD-HHMM`.
|
||||||
|
- Erwartung: Notdienst, Kalender und Adressbuch bleiben standardmäßig aktiv.
|
||||||
|
- [x] Redirect nach Onboarding führt zu `/dashboard`.
|
||||||
|
- Erwartung: Dashboard lädt ohne erneute Anmeldung.
|
||||||
|
- [x] Dashboard zeigt Kita-Namen und Admin-Navigation.
|
||||||
|
- Erwartung: Sidebar zeigt Test-Kita sowie Admin-Menüpunkte wie Familienverwaltung, Dienstplan, Schwarzes Brett und ErzieherInnen.
|
||||||
|
- [x] Familienverwaltung: Familie mit Kind und Elternteil anlegen.
|
||||||
|
- Testdaten: Familie, Elternteil und Kind mit `Codex Live-Test` kennzeichnen.
|
||||||
|
- Erwartung: Familie erscheint in der Liste.
|
||||||
|
- [ ] Einladung wird per E-Mail versendet.
|
||||||
|
- Erwartung: Eltern-Testadresse erhält eine Einladung.
|
||||||
|
- Erst als erledigt markieren, wenn die Mail im Postfach bestätigt wurde.
|
||||||
|
- [ ] Invite-Link öffnet Aktivierungsseite.
|
||||||
|
- Erwartung: Seite `/invite/[token]` zeigt personalisierte Einladung und Kita-Namen.
|
||||||
|
- Invite-Link nicht in diese Datei schreiben.
|
||||||
|
- [ ] Invite-Validierung: Passwort-Pflicht, Passwort-Mismatch, Datenschutz-Pflicht.
|
||||||
|
- Erwartung: fehlende oder unterschiedliche Passwörter und fehlende Datenschutzerklärung erzeugen sichtbare Fehler.
|
||||||
|
- [ ] Elternteil kann Einladung annehmen und wird eingeloggt.
|
||||||
|
- Erwartung: Passwort setzen, Datenschutzerklärung akzeptieren, optional Adressbuch-Opt-in wählen, danach Redirect zu `/dashboard`.
|
||||||
|
- [ ] Elternrolle sieht nur erlaubte Navigation.
|
||||||
|
- Erwartung: Eltern sehen keine Admin-only Punkte wie Familienverwaltung, Dienstplan, Schwarzes Brett-Admin, ErzieherInnen.
|
||||||
|
- [ ] Logout/Login für Admin und Elternteil funktionieren.
|
||||||
|
- Erwartung: beide Testaccounts können sich abmelden und mit `<TEST_PASSWORD>` erneut anmelden.
|
||||||
|
- [x] Zugriffsschutz: `/dashboard` ohne Session leitet zu `/login`.
|
||||||
|
- Erwartung: ausgeloggter Aufruf von `/dashboard` führt zu `/login?callbackUrl=%2Fdashboard` oder entsprechender Login-Route.
|
||||||
|
- [ ] Cleanup: Test-Kita/User/Familie über Superadmin löschen.
|
||||||
|
- Erwartung: Test-Kita wird über Superadmin-Diagnose mit exakter Namensbestätigung gelöscht.
|
||||||
|
- Erst als erledigt markieren, wenn Superadmin-Ansicht keine Test-Kita/Test-User mehr zeigt.
|
||||||
|
- [ ] Nach Cleanup: Test-Login funktioniert nicht mehr / Daten nicht sichtbar.
|
||||||
|
- Erwartung: Admin- und Eltern-Testlogin schlagen fehl; Test-Kita erscheint nicht mehr.
|
||||||
|
|
||||||
|
## Beobachtungen
|
||||||
|
|
||||||
|
- [x] `2026-05-17`: Kontaktformular auf Produktion getestet. Bestätigungsmail an `nolip18234@acanok.com` ist angekommen.
|
||||||
|
- [x] `2026-05-17`: Registrierung mit `hanivan790@acanok.com`, Redirect nach `/onboarding`, Onboarding mit `Codex Live-Test Kita-Verein 20260517-2343` und Dashboard/Admin-Navigation erfolgreich geprüft.
|
||||||
|
- [!] `2026-05-17`: Familienanlage mit Elternadresse `nolip18234@acanok.com` blockiert, weil die E-Mail-Adresse bereits existiert. Mit `kitatester1233@mailaddress.de` erfolgreich fortgesetzt.
|
||||||
|
- [x] `2026-05-17`: Admin-Logout, Zugriffsschutz für `/dashboard` ohne Session und Admin-Login mit temporärem Passwort erfolgreich geprüft. Kombinierter Login-Check bleibt offen, bis ein Elternaccount aktiviert werden kann.
|
||||||
|
- [x] `2026-05-17`: Familie `Codex Live-Test Familie 20260517-2343` mit Kind und Elternteil `kitatester1233@mailaddress.de` erfolgreich angelegt; Status in der Familienverwaltung: `Einladung offen`.
|
||||||
|
- [ ] `2026-05-17`: Im Familien-Details-Dialog ist kein Invite-Link sichtbar; Fortsetzung der Invite-Tests benötigt Mailbestätigung und Invite-Link aus dem Postfach.
|
||||||
|
- [ ] Mailzeit Einladung Elternteil: offen.
|
||||||
|
- [ ] Cleanup-Ergebnis: offen.
|
||||||
|
|
||||||
|
## Pflege-Regeln
|
||||||
|
|
||||||
|
- Erledigte Schritte während des Tests direkt von `[ ]` auf `[x]` setzen.
|
||||||
|
- Blocker mit `[!]` markieren und kurz im Abschnitt Beobachtungen beschreiben.
|
||||||
|
- Mailversand nur als erledigt markieren, wenn die Mail im jeweiligen Postfach bestätigt wurde.
|
||||||
|
- Keine echten Passwörter, Secrets, Tokens oder vollständigen Invite-Links dokumentieren.
|
||||||
|
- Cleanup erst als erledigt markieren, wenn die Testdaten in der Superadmin-Ansicht nicht mehr sichtbar sind.
|
||||||
@@ -659,6 +659,24 @@ async function createAnnouncements(context: SeedContext) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const longTitle = await prisma.announcement.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
title: "Dies ist eine extrem lange Ankuendigung mit einem superlangen Titel um das Layout des Dashboards und des Schwarzen Bretts auf korrekten Textumbruch und Mobilfreundlichkeit zu testen",
|
||||||
|
content: "Inhalt der superlangen Ankuendigung fuer Layout-Testing.",
|
||||||
|
authorId: admin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const xssTitle = await prisma.announcement.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
title: "Boesartiger Titel mit <script>alert('XSS_NEWS_TITLE')</script> HTML Tags",
|
||||||
|
content: "Dieser Beitrag testet, ob die Anwendung XSS-Angriffe im Titel oder im Inhalt filtert. <script>alert('XSS_NEWS_CONTENT')</script>",
|
||||||
|
authorId: admin.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.attachment.create({
|
await prisma.attachment.create({
|
||||||
data: {
|
data: {
|
||||||
announcementId: sommerfest.id,
|
announcementId: sommerfest.id,
|
||||||
@@ -808,6 +826,41 @@ async function createTermine(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const highDensityDay = addDays(now, 7);
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
await prisma.termin.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
title: `Parallel-Termin #${i}`,
|
||||||
|
description: `Beschreibung fuer Parallel-Termin #${i}`,
|
||||||
|
type: TerminType.SONSTIGES,
|
||||||
|
status: TerminStatus.CONFIRMED,
|
||||||
|
startDate: highDensityDay,
|
||||||
|
endDate: highDensityDay,
|
||||||
|
allDay: false,
|
||||||
|
createdById: admin.id,
|
||||||
|
approvedById: admin.id,
|
||||||
|
approvedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.termin.create({
|
||||||
|
data: {
|
||||||
|
kitaId: kita.id,
|
||||||
|
title: "Boesartiger Termin <script>alert('XSS_CAL_TITLE')</script>",
|
||||||
|
description: "XSS Test im Terminkalender <script>alert('XSS_CAL_DESC')</script>",
|
||||||
|
type: TerminType.SONSTIGES,
|
||||||
|
status: TerminStatus.CONFIRMED,
|
||||||
|
startDate: addDays(now, 5),
|
||||||
|
endDate: addDays(now, 5),
|
||||||
|
allDay: true,
|
||||||
|
createdById: admin.id,
|
||||||
|
approvedById: admin.id,
|
||||||
|
approvedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.mitbringselItem.createMany({
|
await prisma.mitbringselItem.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { AbsenceReason } from "@prisma/client";
|
||||||
|
|
||||||
|
export function reasonLabel(reason: AbsenceReason) {
|
||||||
|
if (reason === AbsenceReason.ILLNESS) return "Krank";
|
||||||
|
if (reason === AbsenceReason.VACATION) return "Urlaub";
|
||||||
|
return "Sonstiges";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reasonColor(reason: AbsenceReason) {
|
||||||
|
if (reason === AbsenceReason.ILLNESS) return "var(--danger)";
|
||||||
|
if (reason === AbsenceReason.VACATION) return "var(--warn)";
|
||||||
|
return "var(--accent)";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateRange(startDate: string, endDate: string) {
|
||||||
|
const start = new Date(`${startDate}T00:00:00`);
|
||||||
|
const end = new Date(`${endDate}T00:00:00`);
|
||||||
|
const startLabel = start.toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
});
|
||||||
|
const endLabel = end.toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`;
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@ export function AdminTerminModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (existingTermin) {
|
if (existingTermin) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setFormState({
|
setFormState({
|
||||||
title: existingTermin.title,
|
title: existingTermin.title,
|
||||||
description: existingTermin.description || "",
|
description: existingTermin.description || "",
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ const categories = [
|
|||||||
{ label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" },
|
{ label: "Elternabend", type: TerminType.ELTERNABEND, color: "oklch(0.50 0.10 280)" },
|
||||||
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
|
{ label: "Fest", type: TerminType.KITA_FEST, color: "var(--good)" },
|
||||||
{ label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" },
|
{ label: "Geburtstag", type: TerminType.GEBURTSTAG_INTERN, color: "oklch(0.50 0.10 320)" },
|
||||||
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.60 0.15 160)" },
|
{ label: "Mein Elterndienst", type: "mein-elterndienst", color: "oklch(0.60 0.16 160)" },
|
||||||
|
{ label: "Elterndienst", type: "elterndienst", color: "oklch(0.75 0.08 160)" },
|
||||||
|
{ label: "Mein Notdienst", type: "mein-notdienst", color: "oklch(0.60 0.18 45)" },
|
||||||
|
{ label: "Notdienst", type: "notdienst", color: "oklch(0.75 0.10 50)" },
|
||||||
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
|
{ label: "Sonstiges", type: TerminType.SONSTIGES, color: "var(--ink-muted)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -74,9 +77,10 @@ export function CalendarOverviewClient({
|
|||||||
userId: string;
|
userId: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [view, setView] = useState<"liste" | "monat" | "agenda">("liste");
|
const [view, setView] = useState<"liste" | "monat" | "agenda">("monat");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
|
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set());
|
||||||
|
const [filterScope, setFilterScope] = useState<"own" | "all">(isAdmin ? "all" : "own");
|
||||||
|
|
||||||
// Month navigation state
|
// Month navigation state
|
||||||
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
|
||||||
@@ -104,6 +108,12 @@ export function CalendarOverviewClient({
|
|||||||
// Filter logic
|
// Filter logic
|
||||||
const filteredEvents = useMemo(() => {
|
const filteredEvents = useMemo(() => {
|
||||||
return termine.filter((item) => {
|
return termine.filter((item) => {
|
||||||
|
// 0. Filter by scope (own vs all)
|
||||||
|
if (filterScope === "own") {
|
||||||
|
if (item.kind === "duty" && !item.isOwn) return false;
|
||||||
|
if (item.kind === "notdienst" && !item.isOwn) return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Search Query filter
|
// 1. Search Query filter
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
if (query) {
|
if (query) {
|
||||||
@@ -113,21 +123,34 @@ export function CalendarOverviewClient({
|
|||||||
? item.description.toLowerCase().includes(query)
|
? item.description.toLowerCase().includes(query)
|
||||||
: false;
|
: false;
|
||||||
const familyMatch =
|
const familyMatch =
|
||||||
item.kind === "duty" && item.familyName
|
(item.kind === "duty" || item.kind === "notdienst") && item.familyName
|
||||||
? item.familyName.toLowerCase().includes(query)
|
? item.familyName.toLowerCase().includes(query)
|
||||||
: false;
|
: false;
|
||||||
if (!titleMatch && !descMatch && !familyMatch) return false;
|
const childMatch =
|
||||||
|
item.kind === "notdienst" && item.childName
|
||||||
|
? item.childName.toLowerCase().includes(query)
|
||||||
|
: false;
|
||||||
|
if (!titleMatch && !descMatch && !familyMatch && !childMatch) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Category filter
|
// 2. Category filter
|
||||||
if (activeTypes.size > 0) {
|
if (activeTypes.size > 0) {
|
||||||
const itemType = item.kind === "duty" ? "elterndienst" : item.type;
|
const itemType =
|
||||||
|
item.kind === "duty"
|
||||||
|
? item.isOwn
|
||||||
|
? "mein-elterndienst"
|
||||||
|
: "elterndienst"
|
||||||
|
: item.kind === "notdienst"
|
||||||
|
? item.isOwn
|
||||||
|
? "mein-notdienst"
|
||||||
|
: "notdienst"
|
||||||
|
: item.type;
|
||||||
if (!activeTypes.has(itemType)) return false;
|
if (!activeTypes.has(itemType)) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [termine, searchQuery, activeTypes]);
|
}, [termine, searchQuery, activeTypes, filterScope]);
|
||||||
|
|
||||||
// Check if an event falls on a specific day
|
// Check if an event falls on a specific day
|
||||||
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
|
const isEventOnDay = (item: CalendarListItemDto, day: Date) => {
|
||||||
@@ -164,6 +187,7 @@ export function CalendarOverviewClient({
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Search and Filters Header */}
|
{/* Search and Filters Header */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center flex-wrap gap-3">
|
||||||
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
||||||
{(["liste", "monat", "agenda"] as const).map((v) => (
|
{(["liste", "monat", "agenda"] as const).map((v) => (
|
||||||
<button
|
<button
|
||||||
@@ -183,6 +207,36 @@ export function CalendarOverviewClient({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isAdmin && (
|
||||||
|
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)] p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilterScope("own")}
|
||||||
|
className={[
|
||||||
|
"h-8 px-4 rounded-full text-[12px] font-semibold transition-all duration-200",
|
||||||
|
filterScope === "own"
|
||||||
|
? "bg-[var(--accent-deep)] text-[var(--surface)] shadow-sm"
|
||||||
|
: "text-[var(--ink-soft)] hover:text-[var(--ink)] hover:bg-[var(--surface-2)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
Meine Dienste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilterScope("all")}
|
||||||
|
className={[
|
||||||
|
"h-8 px-4 rounded-full text-[12px] font-semibold transition-all duration-200",
|
||||||
|
filterScope === "all"
|
||||||
|
? "bg-[var(--accent-deep)] text-[var(--surface)] shadow-sm"
|
||||||
|
: "text-[var(--ink-soft)] hover:text-[var(--ink)] hover:bg-[var(--surface-2)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
Alle Dienste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
|
||||||
{/* Category chips */}
|
{/* Category chips */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
@@ -278,16 +332,40 @@ export function CalendarOverviewClient({
|
|||||||
>
|
>
|
||||||
{item.kind === "duty" ? (
|
{item.kind === "duty" ? (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--good-soft)] text-[var(--good)]">
|
<div
|
||||||
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" />
|
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.isOwn
|
||||||
|
? "oklch(0.60 0.16 160)"
|
||||||
|
: "oklch(0.75 0.08 160)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--surface-2)] text-[var(--ink-soft)] ml-2">
|
||||||
|
<WashingMachine className="h-5 w-5 [stroke-width:1.7]" style={{ color: item.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)" }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
|
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h4>
|
</h4>
|
||||||
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)]">
|
<Badge
|
||||||
Elterndienst
|
variant="secondary"
|
||||||
|
style={{
|
||||||
|
color: item.isOwn
|
||||||
|
? "oklch(0.60 0.16 160)"
|
||||||
|
: "oklch(0.75 0.08 160)",
|
||||||
|
borderColor:
|
||||||
|
(item.isOwn
|
||||||
|
? "oklch(0.60 0.16 160)"
|
||||||
|
: "oklch(0.75 0.08 160)") + "44",
|
||||||
|
backgroundColor:
|
||||||
|
(item.isOwn
|
||||||
|
? "oklch(0.60 0.16 160)"
|
||||||
|
: "oklch(0.75 0.08 160)") + "11",
|
||||||
|
}}
|
||||||
|
className="border text-[10px]"
|
||||||
|
>
|
||||||
|
{item.isOwn ? "Mein Elterndienst" : "Elterndienst"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||||
@@ -309,6 +387,59 @@ export function CalendarOverviewClient({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : item.kind === "notdienst" ? (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-3 left-0 w-[4px] rounded-r-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.isOwn
|
||||||
|
? "oklch(0.60 0.18 45)"
|
||||||
|
: "oklch(0.75 0.10 50)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="pl-2 flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h4 className="font-semibold text-[15px] text-[var(--ink)] truncate">
|
||||||
|
{item.title}
|
||||||
|
</h4>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
style={{
|
||||||
|
color: item.isOwn
|
||||||
|
? "oklch(0.60 0.18 45)"
|
||||||
|
: "oklch(0.75 0.10 50)",
|
||||||
|
borderColor:
|
||||||
|
(item.isOwn
|
||||||
|
? "oklch(0.60 0.18 45)"
|
||||||
|
: "oklch(0.75 0.10 50)") + "44",
|
||||||
|
backgroundColor:
|
||||||
|
(item.isOwn
|
||||||
|
? "oklch(0.60 0.18 45)"
|
||||||
|
: "oklch(0.75 0.10 50)") + "11",
|
||||||
|
}}
|
||||||
|
className="border text-[10px]"
|
||||||
|
>
|
||||||
|
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||||
|
Kind: {item.childName} (Familie: {item.familyName})
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
{format(new Date(item.startDate), "dd.MM.yyyy")}
|
||||||
|
</p>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<a href="/dashboard/notdienst/plan">
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs px-2.5">
|
||||||
|
Im Planer bearbeiten
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -526,11 +657,6 @@ function MonthView({
|
|||||||
>
|
>
|
||||||
{format(day, "d")}
|
{format(day, "d")}
|
||||||
</span>
|
</span>
|
||||||
{dayEvents.length > 0 && (
|
|
||||||
<span className="text-[10px] font-semibold text-[var(--ink-muted)] tabular-nums">
|
|
||||||
{dayEvents.length} {dayEvents.length === 1 ? "Eintrag" : "Einträge"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event indicators/snippets */}
|
{/* Event indicators/snippets */}
|
||||||
@@ -538,7 +664,13 @@ function MonthView({
|
|||||||
{dayEvents.slice(0, 3).map((item) => {
|
{dayEvents.slice(0, 3).map((item) => {
|
||||||
const color =
|
const color =
|
||||||
item.kind === "duty"
|
item.kind === "duty"
|
||||||
? "oklch(0.60 0.15 160)"
|
? item.isOwn
|
||||||
|
? "oklch(0.60 0.16 160)"
|
||||||
|
: "oklch(0.75 0.08 160)"
|
||||||
|
: item.kind === "notdienst"
|
||||||
|
? item.isOwn
|
||||||
|
? "oklch(0.60 0.18 45)"
|
||||||
|
: "oklch(0.75 0.10 50)"
|
||||||
: getCategoryColor(item.type);
|
: getCategoryColor(item.type);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -632,8 +764,18 @@ function AgendaView({
|
|||||||
{/* Items stack on right */}
|
{/* Items stack on right */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const isTermin = item.kind === "termin";
|
const color =
|
||||||
const color = isTermin ? getCategoryColor(item.type) : "oklch(0.60 0.15 160)";
|
item.kind === "termin"
|
||||||
|
? getCategoryColor(item.type)
|
||||||
|
: item.kind === "notdienst"
|
||||||
|
? item.isOwn
|
||||||
|
? "oklch(0.60 0.18 45)"
|
||||||
|
: "oklch(0.75 0.10 50)"
|
||||||
|
: item.kind === "duty"
|
||||||
|
? item.isOwn
|
||||||
|
? "oklch(0.60 0.16 160)"
|
||||||
|
: "oklch(0.75 0.08 160)"
|
||||||
|
: "oklch(0.60 0.15 160)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -650,7 +792,7 @@ function AgendaView({
|
|||||||
<h4 className="font-bold text-[15px] text-[var(--ink)] truncate">
|
<h4 className="font-bold text-[15px] text-[var(--ink)] truncate">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h4>
|
</h4>
|
||||||
{isTermin ? (
|
{item.kind === "termin" ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border"
|
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold border"
|
||||||
style={{
|
style={{
|
||||||
@@ -661,34 +803,52 @@ function AgendaView({
|
|||||||
>
|
>
|
||||||
{getTypeLabel(item.type)}
|
{getTypeLabel(item.type)}
|
||||||
</span>
|
</span>
|
||||||
|
) : item.kind === "notdienst" ? (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px]"
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
borderColor: color + "44",
|
||||||
|
backgroundColor: color + "11",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.isOwn ? "Mein Notdienst" : "Notdienst"}
|
||||||
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
|
<Badge variant="secondary" className="bg-[oklch(0.95_0.02_160)] text-[oklch(0.40_0.10_160)] text-[10px]">
|
||||||
Elterndienst
|
Elterndienst
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{isTermin && item.status === TerminStatus.PENDING && (
|
{item.kind === "termin" && item.status === TerminStatus.PENDING && (
|
||||||
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
|
<Badge variant="warning" className="text-[10px]">Ausstehend</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5">
|
<p className="mt-1 text-xs text-[var(--ink-muted)] flex items-center gap-1.5">
|
||||||
<Clock className="h-3.5 w-3.5" />
|
<Clock className="h-3.5 w-3.5" />
|
||||||
{isTermin
|
{item.kind === "termin"
|
||||||
? item.allDay
|
? item.allDay
|
||||||
? "Ganztägig"
|
? "Ganztägig"
|
||||||
: `${format(new Date(item.startDate), "HH:mm")} - ${format(
|
: `${format(new Date(item.startDate), "HH:mm")} - ${format(
|
||||||
new Date(item.endDate),
|
new Date(item.endDate),
|
||||||
"HH:mm",
|
"HH:mm",
|
||||||
)}`
|
)}`
|
||||||
|
: item.kind === "notdienst"
|
||||||
|
? `${format(new Date(item.startDate), "dd.MM.yyyy")} (ganztägig)`
|
||||||
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
|
: `${format(new Date(item.startDate), "dd.MM.")} - ${format(
|
||||||
new Date(item.endDate),
|
new Date(item.endDate),
|
||||||
"dd.MM.yyyy",
|
"dd.MM.yyyy",
|
||||||
)}`}
|
)}`}
|
||||||
{isTermin && <MapPin className="ml-1 h-3.5 w-3.5" />}
|
{item.kind === "termin" && <MapPin className="ml-1 h-3.5 w-3.5" />}
|
||||||
{isTermin ? "Kita" : `Familie: ${item.familyName}`}
|
{item.kind === "termin"
|
||||||
|
? "Kita"
|
||||||
|
: item.kind === "notdienst"
|
||||||
|
? `Kind: ${item.childName} · Familie: ${item.familyName}`
|
||||||
|
: `Familie: ${item.familyName}`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isTermin && item.description && (
|
{item.kind === "termin" && item.description && (
|
||||||
<p className="mt-2 text-sm text-[var(--ink-soft)] line-clamp-2 leading-relaxed">
|
<p className="mt-2 text-sm text-[var(--ink-soft)] line-clamp-2 leading-relaxed">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -697,7 +857,7 @@ function AgendaView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions for admins */}
|
{/* Actions for admins */}
|
||||||
{isAdmin && isTermin && (
|
{isAdmin && item.kind === "termin" && (
|
||||||
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<AdminTerminModal
|
<AdminTerminModal
|
||||||
existingTermin={item}
|
existingTermin={item}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
WashingMachine,
|
WashingMachine,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
ShieldAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -62,9 +63,21 @@ export type DutyCalendarItemDto = {
|
|||||||
status: DutyAssignmentStatus;
|
status: DutyAssignmentStatus;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
|
isOwn: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto;
|
export type NotdienstCalendarItemDto = {
|
||||||
|
kind: "notdienst";
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
familyName: string;
|
||||||
|
childName: string;
|
||||||
|
isOwn: boolean;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarListItemDto = TerminListItemDto | DutyCalendarItemDto | NotdienstCalendarItemDto;
|
||||||
|
|
||||||
export function TerminList({
|
export function TerminList({
|
||||||
termine,
|
termine,
|
||||||
@@ -123,6 +136,8 @@ export function TerminList({
|
|||||||
{group.items.map((item) =>
|
{group.items.map((item) =>
|
||||||
item.kind === "duty" ? (
|
item.kind === "duty" ? (
|
||||||
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
|
<DutyRow key={`duty-${item.id}`} duty={item} isAdmin={isAdmin} />
|
||||||
|
) : item.kind === "notdienst" ? (
|
||||||
|
<NotdienstRow key={`notdienst-${item.id}`} notdienst={item} isAdmin={isAdmin} />
|
||||||
) : (
|
) : (
|
||||||
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
|
<TerminRow key={`termin-${item.id}`} termin={item} userId={userId} isAdmin={isAdmin} />
|
||||||
),
|
),
|
||||||
@@ -136,7 +151,7 @@ export function TerminList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
|
function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolean }) {
|
||||||
const color = "var(--good)";
|
const color = duty.isOwn ? "oklch(0.60 0.16 160)" : "oklch(0.75 0.08 160)";
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -156,7 +171,9 @@ function DutyRow({ duty, isAdmin }: { duty: DutyCalendarItemDto; isAdmin: boolea
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 relative">
|
<div className="flex items-center gap-2 relative">
|
||||||
<CategoryPill color={color}>Elterndienst</CategoryPill>
|
<CategoryPill color={color}>
|
||||||
|
{duty.isOwn ? "Mein Elterndienst" : "Elterndienst"}
|
||||||
|
</CategoryPill>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
@@ -474,3 +491,64 @@ function getTypeLabel(type: TerminType): string {
|
|||||||
return "Sonstiges";
|
return "Sonstiges";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotdienstRow({ notdienst, isAdmin }: { notdienst: NotdienstCalendarItemDto; isAdmin: boolean }) {
|
||||||
|
const color = notdienst.isOwn ? "oklch(0.60 0.18 45)" : "oklch(0.75 0.10 50)";
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative grid grid-cols-[56px_minmax(0,1fr)_auto] items-center gap-4 px-5 py-4 transition-colors hover:bg-[var(--surface-2)]">
|
||||||
|
<div className="absolute inset-y-3 left-0 w-[3px] rounded-r-full" style={{ backgroundColor: color }} />
|
||||||
|
<DateTile date={notdienst.startDate} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate text-[15px] font-semibold text-[var(--ink)]">
|
||||||
|
{notdienst.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 flex flex-wrap items-center gap-2 text-[13px] text-[var(--ink-soft)]">
|
||||||
|
<Clock className="h-3.5 w-3.5 [stroke-width:1.7]" />
|
||||||
|
{format(notdienst.startDate, "dd.MM.yyyy", { locale: de })} (ganztägig)
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[var(--ink-muted)]" />
|
||||||
|
Kind: {notdienst.childName}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 relative">
|
||||||
|
<CategoryPill color={color}>
|
||||||
|
{notdienst.isOwn ? "Mein Notdienst" : "Notdienst"}
|
||||||
|
</CategoryPill>
|
||||||
|
{isAdmin ? (
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Mehr Optionen"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMenuOpen(!menuOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4 [stroke-width:1.7]" />
|
||||||
|
</Button>
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||||
|
<div className="absolute right-0 mt-1 w-56 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-1.5 shadow-lg z-50 animate-in fade-in-50 slide-in-from-top-1 duration-150">
|
||||||
|
<a
|
||||||
|
href="/dashboard/notdienst/plan"
|
||||||
|
className="flex w-full items-center rounded-lg px-2.5 py-2 text-sm text-[var(--ink)] hover:bg-[var(--surface-2)]"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Notdienst im Planer bearbeiten
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-9 h-9" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export default async function KalenderPage({
|
|||||||
startDate: true,
|
startDate: true,
|
||||||
endDate: true,
|
endDate: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
familyId: true,
|
||||||
dutyType: {
|
dutyType: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
@@ -148,6 +149,32 @@ export default async function KalenderPage({
|
|||||||
orderBy: { startDate: "asc" },
|
orderBy: { startDate: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const notdienstAssignmentRows = await prisma.notdienstAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
kitaId: session.user.kitaId,
|
||||||
|
plan: {
|
||||||
|
status: "PUBLISHED",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
date: true,
|
||||||
|
child: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
familyId: true,
|
||||||
|
family: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { date: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
const allUserTermine: CalendarListItemDto[] = [
|
const allUserTermine: CalendarListItemDto[] = [
|
||||||
...confirmedTermineRows.map((termin) => ({
|
...confirmedTermineRows.map((termin) => ({
|
||||||
...termin,
|
...termin,
|
||||||
@@ -169,15 +196,36 @@ export default async function KalenderPage({
|
|||||||
participation.status === EventParticipationStatus.ATTENDING,
|
participation.status === EventParticipationStatus.ATTENDING,
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
...dutyAssignmentRows.map((assignment) => ({
|
...dutyAssignmentRows.map((assignment) => {
|
||||||
|
const isOwn = assignment.familyId === familyIdForParticipation;
|
||||||
|
return {
|
||||||
kind: "duty" as const,
|
kind: "duty" as const,
|
||||||
id: assignment.id,
|
id: assignment.id,
|
||||||
title: assignment.dutyType.name,
|
title: isOwn
|
||||||
|
? `Mein Elterndienst (${assignment.dutyType.name})`
|
||||||
|
: `${assignment.dutyType.name} (${assignment.family.name})`,
|
||||||
familyName: assignment.family.name,
|
familyName: assignment.family.name,
|
||||||
status: assignment.status,
|
status: assignment.status,
|
||||||
startDate: assignment.startDate,
|
startDate: assignment.startDate,
|
||||||
endDate: assignment.endDate,
|
endDate: assignment.endDate,
|
||||||
})),
|
isOwn,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...notdienstAssignmentRows.map((assignment) => {
|
||||||
|
const isOwn = assignment.child.familyId === familyIdForParticipation;
|
||||||
|
return {
|
||||||
|
kind: "notdienst" as const,
|
||||||
|
id: assignment.id,
|
||||||
|
title: isOwn
|
||||||
|
? `Mein Notdienst (${assignment.child.firstName})`
|
||||||
|
: `Notdienst (${assignment.child.family.name})`,
|
||||||
|
familyName: assignment.child.family.name,
|
||||||
|
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
|
||||||
|
isOwn,
|
||||||
|
startDate: assignment.date,
|
||||||
|
endDate: assignment.date,
|
||||||
|
};
|
||||||
|
}),
|
||||||
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
||||||
|
|
||||||
let allPendingTermine: PendingTerminDto[] = [];
|
let allPendingTermine: PendingTerminDto[] = [];
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { LogOut } from "lucide-react";
|
|
||||||
|
|
||||||
import { signOut } from "@/auth";
|
import { signOut } from "@/auth";
|
||||||
import { requireKitaSession } from "@/lib/auth-utils";
|
import { requireKitaSession } from "@/lib/auth-utils";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Button } from "@/components/ui/button";
|
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
|
||||||
import { SidebarNav } from "@/components/dashboard/sidebar-nav";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
robots: {
|
robots: {
|
||||||
@@ -15,11 +13,11 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Dashboard-Layout · Linke Sidebar
|
// Dashboard-Layout · Responsive App Shell
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Server Component: liest kitaId aus der Session, holt den Kita-Namen
|
// Server Component: liest kitaId aus der Session, holt den Kita-Namen
|
||||||
// aus der DB und rendert das App-Shell (Sidebar + main content area).
|
// aus der DB und rendert die responsive DashboardShell (mit Desktop-Sidebar
|
||||||
// Der eigentliche Seiteninhalt kommt via {children} rein.
|
// und Mobile-Drawer).
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
@@ -35,75 +33,20 @@ export default async function DashboardLayout({
|
|||||||
});
|
});
|
||||||
const displayName = session.user.name ?? session.user.email ?? "Kita-Planer";
|
const displayName = session.user.name ?? session.user.email ?? "Kita-Planer";
|
||||||
|
|
||||||
return (
|
async function handleSignOut() {
|
||||||
<div className="grid min-h-screen grid-cols-[256px_minmax(0,1fr)] bg-[var(--bg)] max-[760px]:grid-cols-1">
|
|
||||||
{/* ── Sidebar ───────────────────────────────────────────────────── */}
|
|
||||||
<aside className="print:hidden sticky top-0 flex h-screen shrink-0 flex-col border-r border-[var(--hairline)] bg-[var(--surface)] max-[760px]:relative max-[760px]:h-auto">
|
|
||||||
{/* Logo / Kita-Name */}
|
|
||||||
<div className="flex h-[82px] items-center gap-3 border-b border-[var(--hairline-soft)] px-5">
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
|
|
||||||
{kita.name.slice(0, 1).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
|
|
||||||
{kita.name}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-[var(--ink-soft)]">Kita-Planer</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="flex-1 overflow-y-auto py-6">
|
|
||||||
<SidebarNav role={session.user.role} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer: User-Info + Abmelden */}
|
|
||||||
<div className="border-t border-[var(--hairline-soft)] p-4">
|
|
||||||
<div className="mb-3 flex items-center gap-2.5 px-1">
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
|
|
||||||
{getInitials(displayName)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-xs font-semibold text-[var(--ink)]">
|
|
||||||
{displayName}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
|
|
||||||
{session.user.role}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
action={async () => {
|
|
||||||
"use server";
|
"use server";
|
||||||
await signOut({ redirectTo: "/" });
|
await signOut({ redirectTo: "/" });
|
||||||
}}
|
}
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
Abmelden
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* ── Main Content ──────────────────────────────────────────────── */}
|
return (
|
||||||
<main className="min-w-0 overflow-y-auto">
|
<DashboardShell
|
||||||
|
kitaName={kita.name}
|
||||||
|
userName={displayName}
|
||||||
|
userRole={session.user.role}
|
||||||
|
signOutAction={handleSignOut}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</DashboardShell>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitials(value: string) {
|
|
||||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||||
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
||||||
|
import { NotdienstAssignmentEmail } from "@/emails/NotdienstAssignmentEmail";
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// /dashboard/notdienst/plan · Server Actions
|
// /dashboard/notdienst/plan · Server Actions
|
||||||
@@ -47,8 +48,8 @@ export async function generatePlanAction(year?: number, month?: number) {
|
|||||||
where: {
|
where: {
|
||||||
kitaId,
|
kitaId,
|
||||||
date: {
|
date: {
|
||||||
gte: new Date(targetYear, targetMonth - 1, 1),
|
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
|
||||||
lt: new Date(targetYear, targetMonth, 1),
|
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: { child: { select: { familyId: true } } },
|
include: { child: { select: { familyId: true } } },
|
||||||
@@ -155,17 +156,44 @@ export async function updateAssignmentAction(
|
|||||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||||
const kitaId = session.user.kitaId!;
|
const kitaId = session.user.kitaId!;
|
||||||
|
|
||||||
const parsedDate = new Date(date);
|
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
||||||
|
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction(async (tx) => {
|
const { shouldSendEmails, assignedChildInfo } = await prisma.$transaction(async (tx) => {
|
||||||
// Prüfen, ob Plan noch DRAFT ist
|
let txShouldSendEmails = false;
|
||||||
|
let txAssignedChildInfo: {
|
||||||
|
parentEmails: string[];
|
||||||
|
parentNames: string[];
|
||||||
|
dateLabel: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
const plan = await tx.notdienstPlan.findUnique({
|
const plan = await tx.notdienstPlan.findUnique({
|
||||||
where: { id: planId, kitaId },
|
where: { id: planId, kitaId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!plan || plan.status !== NotdienstPlanStatus.DRAFT) {
|
if (!plan) {
|
||||||
throw new Error("Plan kann nicht mehr bearbeitet werden.");
|
throw new Error("Plan nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
|
||||||
|
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
|
||||||
|
|
||||||
|
if (!isDraft && !isPublished) {
|
||||||
|
throw new Error("Plan kann nicht bearbeitet werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn der Plan bereits veröffentlicht ist, dürfen nur unbesetzte Termine geändert werden.
|
||||||
|
// Ein Termin gilt als "unbesetzt", wenn am Veröffentlichungstag kein Assignment existierte
|
||||||
|
// bzw. das Assignment erst nach dem Veröffentlichungs-Zeitpunkt erstellt wurde.
|
||||||
|
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||||
|
where: { planId, date: parsedDate },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPublished && existingAssignment) {
|
||||||
|
if (!plan.publishedAt || existingAssignment.createdAt <= plan.publishedAt) {
|
||||||
|
throw new Error("In einem bereits veröffentlichten Plan können nur unbesetzte Termine geändert werden.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Altes Assignment für diesen Tag löschen
|
// Altes Assignment für diesen Tag löschen
|
||||||
@@ -181,7 +209,16 @@ export async function updateAssignmentAction(
|
|||||||
kitaId,
|
kitaId,
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
include: {
|
||||||
|
family: {
|
||||||
|
include: {
|
||||||
|
users: {
|
||||||
|
where: { role: UserRole.ELTERN },
|
||||||
|
select: { email: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!child) {
|
if (!child) {
|
||||||
@@ -196,9 +233,57 @@ export async function updateAssignmentAction(
|
|||||||
date: parsedDate,
|
date: parsedDate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wenn der Plan bereits veröffentlicht ist, bereiten wir E-Mail-Benachrichtigungen vor
|
||||||
|
if (isPublished) {
|
||||||
|
const parents = child.family.users.filter((u) => !!u.email);
|
||||||
|
if (parents.length > 0) {
|
||||||
|
const localFormatDate = new Date(yr, mo - 1, dy);
|
||||||
|
txAssignedChildInfo = {
|
||||||
|
parentEmails: parents.map((p) => p.email!),
|
||||||
|
parentNames: parents.map((p) => `${p.firstName} ${p.lastName}`),
|
||||||
|
dateLabel: format(localFormatDate, "EEEE, dd. MMMM yyyy", { locale: de }),
|
||||||
|
};
|
||||||
|
txShouldSendEmails = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldSendEmails: txShouldSendEmails,
|
||||||
|
assignedChildInfo: txAssignedChildInfo,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// E-Mails außerhalb der DB-Transaktion senden
|
||||||
|
if (shouldSendEmails && assignedChildInfo) {
|
||||||
|
const kita = await prisma.kita.findUnique({
|
||||||
|
where: { id: kitaId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (kita) {
|
||||||
|
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||||
|
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||||
|
|
||||||
|
for (let i = 0; i < assignedChildInfo.parentEmails.length; i++) {
|
||||||
|
const email = assignedChildInfo.parentEmails[i];
|
||||||
|
const name = assignedChildInfo.parentNames[i];
|
||||||
|
|
||||||
|
await sendAppEmail({
|
||||||
|
to: email,
|
||||||
|
subject: `Notdienst-Einteilung am ${assignedChildInfo.dateLabel}`,
|
||||||
|
react: createElement(NotdienstAssignmentEmail, {
|
||||||
|
parentName: name,
|
||||||
|
kitaName: kita.name,
|
||||||
|
dateLabel: assignedChildInfo.dateLabel,
|
||||||
|
link,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/dashboard/notdienst/plan");
|
revalidatePath("/dashboard/notdienst/plan");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ export default async function PlanungsZentralePage({
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
status: true,
|
status: true,
|
||||||
|
publishedAt: true,
|
||||||
assignments: {
|
assignments: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
childId: true,
|
childId: true,
|
||||||
date: true,
|
date: true,
|
||||||
|
createdAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -111,6 +113,7 @@ export default async function PlanungsZentralePage({
|
|||||||
id: true,
|
id: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
|
familyId: true,
|
||||||
family: {
|
family: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
@@ -135,6 +138,7 @@ export default async function PlanungsZentralePage({
|
|||||||
id: c.id,
|
id: c.id,
|
||||||
name: `${c.firstName} ${c.lastName}`,
|
name: `${c.firstName} ${c.lastName}`,
|
||||||
parentName,
|
parentName,
|
||||||
|
familyId: c.familyId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,10 +181,15 @@ export default async function PlanungsZentralePage({
|
|||||||
const assignment = plan?.assignments.find(
|
const assignment = plan?.assignments.find(
|
||||||
(a) => a.date.getTime() === day.getTime()
|
(a) => a.date.getTime() === day.getTime()
|
||||||
);
|
);
|
||||||
|
const isOriginallyUnassigned =
|
||||||
|
!assignment ||
|
||||||
|
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: day.toISOString(),
|
date: day.toISOString(),
|
||||||
assignmentId: assignment?.id || null,
|
assignmentId: assignment?.id || null,
|
||||||
childId: assignment?.childId || null,
|
childId: assignment?.childId || null,
|
||||||
|
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { NotdienstPlanStatus } from "@prisma/client";
|
import { NotdienstPlanStatus } from "@prisma/client";
|
||||||
@@ -41,11 +41,13 @@ interface PlanViewProps {
|
|||||||
date: string;
|
date: string;
|
||||||
assignmentId: string | null;
|
assignmentId: string | null;
|
||||||
childId: string | null;
|
childId: string | null;
|
||||||
|
isOriginallyUnassigned?: boolean;
|
||||||
}[];
|
}[];
|
||||||
allChildren: {
|
allChildren: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
parentName: string;
|
parentName: string;
|
||||||
|
familyId: string;
|
||||||
}[];
|
}[];
|
||||||
familyStatuses: {
|
familyStatuses: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -140,7 +142,7 @@ export function PlanView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAssignmentChange = (date: string, newChildId: string) => {
|
const handleAssignmentChange = (date: string, newChildId: string) => {
|
||||||
if (!planId || status !== "DRAFT") return;
|
if (!planId || (status !== "DRAFT" && status !== "PUBLISHED")) return;
|
||||||
const valueToSet = newChildId === "empty" ? null : newChildId;
|
const valueToSet = newChildId === "empty" ? null : newChildId;
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -517,7 +519,8 @@ function PlanPreviewTable({
|
|||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||||
{days.map((day) => {
|
{days.map((day) => {
|
||||||
const dateObj = new Date(day.date);
|
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
||||||
|
const dateObj = new Date(yr, mo - 1, dy);
|
||||||
const assignedChild = allChildren.find(
|
const assignedChild = allChildren.find(
|
||||||
(child) => child.id === day.childId,
|
(child) => child.id === day.childId,
|
||||||
);
|
);
|
||||||
@@ -566,6 +569,31 @@ function ManualAssignmentTable({
|
|||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onAssignmentChange: (date: string, newChildId: string) => void;
|
onAssignmentChange: (date: string, newChildId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// Map childId to familyId for quick lookup
|
||||||
|
const childFamilyMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
allChildren.forEach((child) => {
|
||||||
|
if (child.familyId) {
|
||||||
|
map.set(child.id, child.familyId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [allChildren]);
|
||||||
|
|
||||||
|
// Compute family assignment counts in real-time based on current selections
|
||||||
|
const familyAssignmentCounts = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
days.forEach((day) => {
|
||||||
|
if (day.childId) {
|
||||||
|
const famId = childFamilyMap.get(day.childId);
|
||||||
|
if (famId) {
|
||||||
|
counts.set(famId, (counts.get(famId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [days, childFamilyMap]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden p-0">
|
<Card className="overflow-hidden p-0">
|
||||||
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
|
||||||
@@ -574,7 +602,8 @@ function ManualAssignmentTable({
|
|||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-[var(--hairline-soft)]">
|
<div className="divide-y divide-[var(--hairline-soft)]">
|
||||||
{days.map((day) => {
|
{days.map((day) => {
|
||||||
const dateObj = new Date(day.date);
|
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
||||||
|
const dateObj = new Date(yr, mo - 1, dy);
|
||||||
const assignedChild = allChildren.find(
|
const assignedChild = allChildren.find(
|
||||||
(child) => child.id === day.childId,
|
(child) => child.id === day.childId,
|
||||||
);
|
);
|
||||||
@@ -588,7 +617,8 @@ function ManualAssignmentTable({
|
|||||||
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{isDraft ? (
|
{isDraft || day.isOriginallyUnassigned ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<select
|
<select
|
||||||
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
|
||||||
value={day.childId || "empty"}
|
value={day.childId || "empty"}
|
||||||
@@ -598,12 +628,22 @@ function ManualAssignmentTable({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="empty">-- Unbesetzt --</option>
|
<option value="empty">-- Unbesetzt --</option>
|
||||||
{allChildren.map((child) => (
|
{allChildren.map((child) => {
|
||||||
|
const famId = child.familyId;
|
||||||
|
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
|
||||||
|
return (
|
||||||
<option key={child.id} value={child.id}>
|
<option key={child.id} value={child.id}>
|
||||||
{child.parentName} ({child.name})
|
{child.parentName} ({child.name}) — {count}x
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
|
{!isDraft && (
|
||||||
|
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
|
||||||
|
Nachträglich änderbar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : assignedChild ? (
|
) : assignedChild ? (
|
||||||
<span className="text-sm text-[var(--ink)]">
|
<span className="text-sm text-[var(--ink)]">
|
||||||
{assignedChild.parentName}{" "}
|
{assignedChild.parentName}{" "}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type ContactFormProps = {
|
|||||||
street: string | null;
|
street: string | null;
|
||||||
postalCode: string | null;
|
postalCode: string | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
|
directoryOptIn: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export function ContactForm({ contact }: ContactFormProps) {
|
|||||||
street: String(formData.get("street") ?? ""),
|
street: String(formData.get("street") ?? ""),
|
||||||
postalCode: String(formData.get("postalCode") ?? ""),
|
postalCode: String(formData.get("postalCode") ?? ""),
|
||||||
city: String(formData.get("city") ?? ""),
|
city: String(formData.get("city") ?? ""),
|
||||||
|
directoryOptIn: formData.get("directoryOptIn") === "on",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
@@ -106,6 +108,22 @@ export function ContactForm({ contact }: ContactFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="directoryOptIn"
|
||||||
|
name="directoryOptIn"
|
||||||
|
defaultChecked={contact.directoryOptIn}
|
||||||
|
className="rounded border-[var(--hairline)] accent-[var(--accent-deep)]"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="directoryOptIn"
|
||||||
|
className="font-normal cursor-pointer text-xs text-[var(--ink-soft)]"
|
||||||
|
>
|
||||||
|
Im Adressbuch anzeigen (Datenschutzerklärung)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-1">
|
<div className="flex justify-end pt-1">
|
||||||
<Button type="submit" disabled={isPending}>
|
<Button type="submit" disabled={isPending}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const contactSchema = z.object({
|
|||||||
"Bitte eine gültige Postleitzahl angeben.",
|
"Bitte eine gültige Postleitzahl angeben.",
|
||||||
),
|
),
|
||||||
city: z.string().trim().max(100).optional(),
|
city: z.string().trim().max(100).optional(),
|
||||||
|
directoryOptIn: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
|
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
|
||||||
@@ -183,6 +184,20 @@ export async function updateMyContact(rawPayload: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const currentUser = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { directoryOptInAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let newDirectoryOptInAt: Date | null = currentUser.directoryOptInAt;
|
||||||
|
if (parsed.data.directoryOptIn) {
|
||||||
|
if (!newDirectoryOptInAt) {
|
||||||
|
newDirectoryOptInAt = new Date();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newDirectoryOptInAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -190,6 +205,7 @@ export async function updateMyContact(rawPayload: unknown) {
|
|||||||
street: parsed.data.street || null,
|
street: parsed.data.street || null,
|
||||||
postalCode: parsed.data.postalCode || null,
|
postalCode: parsed.data.postalCode || null,
|
||||||
city: parsed.data.city || null,
|
city: parsed.data.city || null,
|
||||||
|
directoryOptInAt: newDirectoryOptInAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default async function ProfilPage() {
|
|||||||
street: true,
|
street: true,
|
||||||
postalCode: true,
|
postalCode: true,
|
||||||
city: true,
|
city: true,
|
||||||
|
directoryOptInAt: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
family: {
|
family: {
|
||||||
select: {
|
select: {
|
||||||
@@ -173,6 +174,7 @@ export default async function ProfilPage() {
|
|||||||
street: user.street,
|
street: user.street,
|
||||||
postalCode: user.postalCode,
|
postalCode: user.postalCode,
|
||||||
city: user.city,
|
city: user.city,
|
||||||
|
directoryOptIn: !!user.directoryOptInAt,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -10,8 +10,24 @@ import { loginAction, type LoginState } from "./actions";
|
|||||||
|
|
||||||
const initialState: LoginState = {};
|
const initialState: LoginState = {};
|
||||||
|
|
||||||
export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
|
export function LoginForm({
|
||||||
|
callbackUrl,
|
||||||
|
devAdminEmail,
|
||||||
|
devAdminPassword,
|
||||||
|
devParentEmail,
|
||||||
|
devParentPassword,
|
||||||
|
}: {
|
||||||
|
callbackUrl: string;
|
||||||
|
devAdminEmail?: string;
|
||||||
|
devAdminPassword?: string;
|
||||||
|
devParentEmail?: string;
|
||||||
|
devParentPassword?: string;
|
||||||
|
}) {
|
||||||
const [state, formAction, pending] = useActionState(loginAction, initialState);
|
const [state, formAction, pending] = useActionState(loginAction, initialState);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const showDevHelpers = !!(devAdminEmail || devParentEmail);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={formAction} className="space-y-4">
|
<form action={formAction} className="space-y-4">
|
||||||
@@ -26,6 +42,8 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
aria-invalid={!!state.errors?.email}
|
aria-invalid={!!state.errors?.email}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{state.errors?.email?.[0] && (
|
{state.errors?.email?.[0] && (
|
||||||
<p className="text-xs text-destructive">{state.errors.email[0]}</p>
|
<p className="text-xs text-destructive">{state.errors.email[0]}</p>
|
||||||
@@ -41,6 +59,8 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
aria-invalid={!!state.errors?.password}
|
aria-invalid={!!state.errors?.password}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{state.errors?.password?.[0] && (
|
{state.errors?.password?.[0] && (
|
||||||
<p className="text-xs text-destructive">{state.errors.password[0]}</p>
|
<p className="text-xs text-destructive">{state.errors.password[0]}</p>
|
||||||
@@ -56,6 +76,42 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
|
|||||||
<Button type="submit" className="w-full" disabled={pending}>
|
<Button type="submit" className="w-full" disabled={pending}>
|
||||||
{pending ? "Anmelden…" : "Anmelden"}
|
{pending ? "Anmelden…" : "Anmelden"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{showDevHelpers && (
|
||||||
|
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/40 p-3 space-y-2 text-xs">
|
||||||
|
<p className="text-center font-medium text-muted-foreground">Dev-Schnellzugriff (nur lokal):</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{devAdminEmail && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEmail(devAdminEmail);
|
||||||
|
setPassword(devAdminPassword || "");
|
||||||
|
}}
|
||||||
|
className="text-xs py-1.5 h-auto cursor-pointer"
|
||||||
|
>
|
||||||
|
🔑 Admin
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{devParentEmail && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEmail(devParentEmail);
|
||||||
|
setPassword(devParentPassword || "");
|
||||||
|
}}
|
||||||
|
className="text-xs py-1.5 h-auto cursor-pointer"
|
||||||
|
>
|
||||||
|
👪 Eltern
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -36,6 +36,12 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl);
|
const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl);
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined;
|
||||||
|
const devAdminPassword = isDev ? process.env.DEV_LOGIN_ADMIN_PASSWORD : undefined;
|
||||||
|
const devParentEmail = isDev ? process.env.DEV_LOGIN_PARENT_EMAIL : undefined;
|
||||||
|
const devParentPassword = isDev ? process.env.DEV_LOGIN_PARENT_PASSWORD : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
@@ -44,7 +50,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
<CardDescription>Willkommen zurück.</CardDescription>
|
<CardDescription>Willkommen zurück.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<LoginForm callbackUrl={callbackUrl} />
|
<LoginForm
|
||||||
|
callbackUrl={callbackUrl}
|
||||||
|
devAdminEmail={devAdminEmail}
|
||||||
|
devAdminPassword={devAdminPassword}
|
||||||
|
devParentEmail={devParentEmail}
|
||||||
|
devParentPassword={devParentPassword}
|
||||||
|
/>
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Noch keinen Account?{" "}
|
Noch keinen Account?{" "}
|
||||||
<Link href="/register" className="font-medium text-foreground underline">
|
<Link href="/register" className="font-medium text-foreground underline">
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { LogOut, Menu, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { SidebarNav } from "./sidebar-nav";
|
||||||
|
import { UserRole } from "@prisma/client";
|
||||||
|
|
||||||
|
type DashboardShellProps = {
|
||||||
|
kitaName: string;
|
||||||
|
userName: string;
|
||||||
|
userRole: UserRole;
|
||||||
|
children: React.ReactNode;
|
||||||
|
signOutAction: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardShell({
|
||||||
|
kitaName,
|
||||||
|
userName,
|
||||||
|
userRole,
|
||||||
|
children,
|
||||||
|
signOutAction,
|
||||||
|
}: DashboardShellProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const displayName = userName;
|
||||||
|
const initials = getInitials(displayName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-screen grid-cols-[256px_minmax(0,1fr)] bg-[var(--bg)] max-[760px]:grid-cols-1">
|
||||||
|
{/* ── Desktop Sidebar ── */}
|
||||||
|
<aside className="print:hidden sticky top-0 flex h-screen shrink-0 flex-col border-r border-[var(--hairline)] bg-[var(--surface)] max-[760px]:hidden">
|
||||||
|
{/* Logo / Kita-Name */}
|
||||||
|
<div className="flex h-[82px] items-center gap-3 border-b border-[var(--hairline-soft)] px-5">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
|
||||||
|
{kitaName.slice(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
|
||||||
|
{kitaName}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--ink-soft)]">Kita-Planer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-6">
|
||||||
|
<SidebarNav role={userRole} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: User-Info + Abmelden */}
|
||||||
|
<div className="border-t border-[var(--hairline-soft)] p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2.5 px-1">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-xs font-semibold text-[var(--ink)]">
|
||||||
|
{displayName}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
|
||||||
|
{userRole}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form action={signOutAction}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-[var(--ink-soft)] hover:text-[var(--danger)]"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ── Mobile Top Header Bar ── */}
|
||||||
|
<header className="print:hidden hidden max-[760px]:flex sticky top-0 z-40 h-16 items-center justify-between border-b border-[var(--hairline)] bg-[var(--surface)] px-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-deep)] text-[13px] font-bold text-white">
|
||||||
|
{kitaName.slice(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold leading-none text-[var(--ink)]">
|
||||||
|
{kitaName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
aria-label={isOpen ? "Menü schließen" : "Menü öffnen"}
|
||||||
|
>
|
||||||
|
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Mobile Sidebar Drawer/Overlay ── */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="print:hidden fixed inset-0 z-30 hidden max-[760px]:block">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-xs transition-opacity"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
{/* Drawer content */}
|
||||||
|
<aside className="fixed bottom-0 top-16 left-0 flex w-[280px] max-w-[85vw] flex-col border-r border-[var(--hairline)] bg-[var(--surface)] shadow-2xl transition-transform">
|
||||||
|
<div className="flex-1 overflow-y-auto py-6" onClick={() => setIsOpen(false)}>
|
||||||
|
<SidebarNav role={userRole} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-[var(--hairline-soft)] p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2.5 px-1">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[11px] font-semibold text-[var(--accent-deep)]">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-xs font-semibold text-[var(--ink)]">
|
||||||
|
{displayName}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[11px] font-semibold uppercase text-[var(--ink-muted)]">
|
||||||
|
{userRole}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form action={signOutAction}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-[var(--ink-soft)] hover:text-[var(--danger)]"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Main Content Area ── */}
|
||||||
|
<main className="min-w-0 overflow-y-auto max-[760px]:pt-0">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(value: string) {
|
||||||
|
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return value.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
|
||||||
|
import { baseBodyStyle, colors, fontStack } from "./_styles";
|
||||||
|
|
||||||
|
type NotdienstAssignmentEmailProps = {
|
||||||
|
parentName: string;
|
||||||
|
kitaName: string;
|
||||||
|
dateLabel: string;
|
||||||
|
link: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotdienstAssignmentEmail({
|
||||||
|
parentName,
|
||||||
|
kitaName,
|
||||||
|
dateLabel,
|
||||||
|
link,
|
||||||
|
}: NotdienstAssignmentEmailProps) {
|
||||||
|
return (
|
||||||
|
<Html lang="de">
|
||||||
|
<Head />
|
||||||
|
<Preview>Aktualisierung Notdienst-Plan: Zuteilung für {dateLabel}</Preview>
|
||||||
|
<Body style={styles.body}>
|
||||||
|
<Container style={styles.container}>
|
||||||
|
<Section style={styles.card}>
|
||||||
|
<Section style={styles.brandBar}>
|
||||||
|
<Text style={styles.brand}>Kita-Planer</Text>
|
||||||
|
</Section>
|
||||||
|
<Text style={styles.kicker}>Notdienst-Einteilung</Text>
|
||||||
|
<Heading style={styles.heading}>Neuer Notdienst-Termin zugewiesen</Heading>
|
||||||
|
<Text style={styles.lead}>
|
||||||
|
Hallo {parentName},
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.text}>
|
||||||
|
in der Kita <strong>{kitaName}</strong> gab es eine Änderung an der Notdienst-Planung.
|
||||||
|
Du wurdest für folgenden Tag für den Notdienst eingeteilt:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={styles.dateBadge}>
|
||||||
|
<Text style={styles.dateText}>{dateLabel}</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text style={styles.text}>
|
||||||
|
Bitte merke dir diesen Termin vor. Du kannst den vollständigen Plan auch jederzeit online im Kita-Planer einsehen.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button href={link} style={styles.button}>
|
||||||
|
Zum Kita-Planer
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Text style={styles.fallbackText}>
|
||||||
|
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.linkText}>{link}</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text style={styles.footer}>
|
||||||
|
Diese Nachricht wurde automatisch vom Kita-Planer deiner Kita versendet.
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
body: baseBodyStyle,
|
||||||
|
container: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "600px",
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "32px 20px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
padding: "0 28px 28px",
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: "10px",
|
||||||
|
border: `1px solid ${colors.border}`,
|
||||||
|
boxShadow: "0 10px 26px rgba(31, 59, 45, 0.08)",
|
||||||
|
overflow: "hidden",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
brandBar: {
|
||||||
|
margin: "0 -28px 28px",
|
||||||
|
padding: "14px 28px",
|
||||||
|
backgroundColor: colors.brandGreen,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
margin: 0,
|
||||||
|
color: colors.eyebrow,
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
kicker: {
|
||||||
|
margin: "0 0 10px",
|
||||||
|
color: colors.brandGreenAccent,
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
margin: "0 0 14px",
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontSize: "28px",
|
||||||
|
lineHeight: "34px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
lead: {
|
||||||
|
margin: "0 0 22px",
|
||||||
|
color: colors.textBody,
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
margin: "0 0 24px",
|
||||||
|
color: colors.textBody,
|
||||||
|
fontSize: "15px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
dateBadge: {
|
||||||
|
padding: "16px",
|
||||||
|
backgroundColor: "#f7f5ef",
|
||||||
|
borderLeft: `4px solid ${colors.brandGreen}`,
|
||||||
|
borderRadius: "4px",
|
||||||
|
margin: "0 0 24px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: colors.textPrimary,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "14px 20px",
|
||||||
|
backgroundColor: colors.brandGreen,
|
||||||
|
color: "#ffffff",
|
||||||
|
borderRadius: "6px",
|
||||||
|
fontSize: "15px",
|
||||||
|
fontWeight: 700,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
fallbackText: {
|
||||||
|
margin: "28px 0 8px",
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: "20px",
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
margin: 0,
|
||||||
|
color: colors.link,
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: "20px",
|
||||||
|
wordBreak: "break-all" as const,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
margin: "20px 0 0",
|
||||||
|
color: colors.textFaint,
|
||||||
|
fontSize: "12px",
|
||||||
|
lineHeight: "18px",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
fontFamily: fontStack,
|
||||||
|
},
|
||||||
|
};
|
||||||
+19
-13
@@ -1,13 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
addMonths,
|
addMonths,
|
||||||
endOfMonth,
|
|
||||||
getDate,
|
getDate,
|
||||||
getYear,
|
getYear,
|
||||||
getMonth,
|
getMonth,
|
||||||
isWeekend,
|
|
||||||
eachDayOfInterval,
|
|
||||||
startOfMonth,
|
|
||||||
startOfDay,
|
|
||||||
format,
|
format,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
@@ -36,17 +31,28 @@ export function getTargetMonthData(now = new Date()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkingDaysOfMonth(year: number, month: number) {
|
export function getWorkingDaysOfMonth(year: number, month: number) {
|
||||||
// month ist 1-12, Date erwartet 0-11
|
const start = new Date(Date.UTC(year, month - 1, 1));
|
||||||
const start = startOfMonth(new Date(year, month - 1));
|
const end = new Date(Date.UTC(year, month, 0)); // last day of month
|
||||||
const end = endOfMonth(start);
|
|
||||||
|
|
||||||
const days = eachDayOfInterval({ start, end });
|
const days: Date[] = [];
|
||||||
|
const current = new Date(start);
|
||||||
return days
|
while (current <= end) {
|
||||||
.filter((day) => !isWeekend(day))
|
const dayOfWeek = current.getUTCDay(); // 0 is Sunday, 6 is Saturday
|
||||||
.map((day) => startOfDay(day));
|
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||||
|
days.push(new Date(current));
|
||||||
|
}
|
||||||
|
current.setUTCDate(current.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateKey(date: Date) {
|
export function formatDateKey(date: Date) {
|
||||||
return format(date, "yyyy-MM-dd");
|
return format(date, "yyyy-MM-dd");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatUtcDateKey(date: Date) {
|
||||||
|
const yyyy = date.getUTCFullYear();
|
||||||
|
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||||
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ type SendAppEmailResult =
|
|||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
export function getAppEmailConfigError() {
|
export function getAppEmailConfigError() {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (!process.env.RESEND_API_KEY) {
|
||||||
return "RESEND_API_KEY ist nicht konfiguriert.";
|
return "RESEND_API_KEY ist nicht konfiguriert.";
|
||||||
}
|
}
|
||||||
@@ -35,6 +38,16 @@ export async function sendAppEmail({
|
|||||||
replyTo,
|
replyTo,
|
||||||
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
}: SendAppEmailInput): Promise<SendAppEmailResult> {
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (!process.env.RESEND_API_KEY) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.log("\n✉️ [DEV EMAIL SIMULATION]");
|
||||||
|
console.log(`From: ${from || "not-configured"}`);
|
||||||
|
console.log(`To: ${Array.isArray(to) ? to.join(", ") : to}`);
|
||||||
|
console.log(`Subject: ${subject}`);
|
||||||
|
console.log("-----------------------------------------");
|
||||||
|
console.log("Email body react element is rendered.");
|
||||||
|
console.log("=========================================\n");
|
||||||
|
return { success: true, id: "dev-simulated-id" };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "RESEND_API_KEY ist nicht konfiguriert.",
|
error: "RESEND_API_KEY ist nicht konfiguriert.",
|
||||||
|
|||||||
Reference in New Issue
Block a user