feat: show planning availability and manage Kita access roles

This commit is contained in:
t.indorf
2026-10-12 16:28:56 +02:00
parent 99d3a63eed
commit 061024d901
29 changed files with 2347 additions and 404 deletions
+146 -41
View File
@@ -121,8 +121,14 @@ export function PlanView({
if ("error" in result && result.error) {
toast.error(result.error as string);
} else if ("success" in result && result.success) {
if (result.failed || ("deferred" in result && result.deferred)) toast.warning("Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.");
else toast.success("Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.");
if (result.failed || ("deferred" in result && result.deferred))
toast.warning(
"Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.",
);
else
toast.success(
"Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.",
);
}
});
};
@@ -133,8 +139,14 @@ export function PlanView({
if ("error" in result && result.error) {
toast.error(result.error as string);
} else if ("success" in result && result.success) {
if (result.failed || ("deferred" in result && result.deferred)) toast.warning(`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`);
else toast.success(`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`);
if (result.failed || ("deferred" in result && result.deferred))
toast.warning(
`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`,
);
else
toast.success(
`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`,
);
}
});
};
@@ -157,7 +169,11 @@ export function PlanView({
const valueToSet = newChildId === "empty" ? null : newChildId;
startTransition(async () => {
const result = await updateAssignmentAction(planId, date.slice(0, 10), valueToSet);
const result = await updateAssignmentAction(
planId,
date.slice(0, 10),
valueToSet,
);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
@@ -248,7 +264,10 @@ export function PlanView({
{family.childCount === 1 ? "" : "er"}
</p>
</div>
<FamilyStatusPill selectedDays={family.selectedDays.length} requiredDays={family.requiredDays} />
<FamilyStatusPill
selectedDays={family.selectedDays.length}
requiredDays={family.requiredDays}
/>
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
{family.selectedDays.length > 0
? family.selectedDays.map((day) => day.label).join(" · ")
@@ -318,8 +337,8 @@ export function PlanView({
Plan automatisch generieren
</h3>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Verteilt {requiredDays} Notdiensttage fair auf{" "}
die verfügbaren Familien.
Verteilt {requiredDays} Notdiensttage fair auf die verfügbaren
Familien.
</p>
<details className="mt-3 text-xs text-[var(--ink-soft)]">
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
@@ -412,6 +431,7 @@ export function PlanView({
<ManualAssignmentTable
days={days}
allChildren={allChildren}
familyStatuses={familyStatuses}
isDraft={isDraft}
isPending={isPending}
onAssignmentChange={handleAssignmentChange}
@@ -451,7 +471,13 @@ export function PlanView({
);
}
function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number; requiredDays: number }) {
function FamilyStatusPill({
selectedDays,
requiredDays,
}: {
selectedDays: number;
requiredDays: number;
}) {
if (selectedDays === 0) {
return (
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
@@ -461,7 +487,13 @@ function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number
}
return (
<span className={selectedDays >= requiredDays ? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]" : "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"}>
<span
className={
selectedDays >= requiredDays
? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]"
: "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"
}
>
{selectedDays} / {requiredDays} Tage
</span>
);
@@ -552,7 +584,11 @@ function PlanPreviewTable({
{format(dateObj, "EEEE", { locale: de })}
</div>
<div className="text-[var(--ink)]">
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"}
{assignedChild
? assignedChild.parentName
: day.childId
? "Inaktives Kind"
: "Unbesetzt"}
{assignedChild && (
<span className="ml-1 text-[var(--ink-muted)]">
({assignedChild.name})
@@ -576,8 +612,20 @@ function PlanPreviewTable({
</div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
<Badge variant={day.isInvalid ? "warning" : assignedChild ? "success" : "destructive"}>
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"}
<Badge
variant={
day.isInvalid
? "warning"
: assignedChild
? "success"
: "destructive"
}
>
{day.isInvalid
? "Bitte korrigieren"
: assignedChild
? "Besetzt"
: "Offen"}
</Badge>
</div>
</div>
@@ -588,17 +636,29 @@ function PlanPreviewTable({
);
}
function ManualAssignmentTable({
export function ManualAssignmentTable({
days,
allChildren,
familyStatuses,
isDraft,
isPending,
onAssignmentChange,
}: Pick<PlanViewProps, "days" | "allChildren"> & {
}: Pick<PlanViewProps, "days" | "allChildren" | "familyStatuses"> & {
isDraft: boolean;
isPending: boolean;
onAssignmentChange: (date: string, newChildId: string) => void;
}) {
const availabilityByFamily = useMemo(
() =>
new Map(
familyStatuses.map((family) => [
family.id,
new Set(family.selectedDays.map((day) => day.iso.slice(0, 10))),
]),
),
[familyStatuses],
);
// Map childId to familyId for quick lookup
const childFamilyMap = useMemo(() => {
const map = new Map<string, string>();
@@ -626,7 +686,12 @@ function ManualAssignmentTable({
return (
<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="border-b border-[var(--hairline-soft)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
Verfügbare Familien stehen zuerst. Familien ohne Verfügbarkeit für den
Tag bleiben für abgestimmte Sonderfälle auswählbar. Die Einteilungszahl
gilt für den gesamten Haushalt.
</div>
<div className="hidden sm: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>Datum</div>
<div>Eingeteilte Familie</div>
</div>
@@ -634,42 +699,82 @@ function ManualAssignmentTable({
{days.map((day) => {
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
const dateObj = new Date(yr, mo - 1, dy);
const dateKey = day.date.slice(0, 10);
const available = allChildren.filter((child) =>
availabilityByFamily.get(child.familyId)?.has(dateKey),
);
const unavailable = allChildren.filter(
(child) => !availabilityByFamily.get(child.familyId)?.has(dateKey),
);
const availableFamilies = new Set(
available.map((child) => child.familyId),
).size;
const selectedChild = allChildren.find(
(child) => child.id === day.childId,
);
const selectedAvailable =
!!selectedChild &&
availabilityByFamily.get(selectedChild.familyId)?.has(dateKey);
const option = (child: PlanViewProps["allChildren"][number]) => (
<option key={child.id} value={child.id} disabled={day.isInvalid}>
{child.parentName} ({child.name}) —{" "}
{familyAssignmentCounts.get(child.familyId) ?? 0}x
</option>
);
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
className="grid gap-3 sm:grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="font-medium text-[var(--ink)]">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div className="flex flex-wrap items-center gap-2">
<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)]"
value={day.childId || "empty"}
disabled={isPending || !!day.activeAlarmId}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
>
<option value="empty">-- Unbesetzt --</option>
{day.childId && !allChildren.some((child) => child.id === day.childId) ? (
<option value={day.childId} disabled>Inaktives Kind – bitte Einteilung entfernen</option>
) : null}
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId
? (familyAssignmentCounts.get(famId) ?? 0)
: 0;
return (
<option key={child.id} value={child.id} disabled={day.isInvalid}>
{child.parentName} ({child.name}) — {count}x
<div className="w-full max-w-sm space-y-2">
<select
aria-label={`Familie für ${format(dateObj, "dd.MM.yyyy")}`}
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"}
disabled={isPending || !!day.activeAlarmId}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
>
<option value="empty">-- Unbesetzt --</option>
{day.childId &&
!allChildren.some((child) => child.id === day.childId) ? (
<option value={day.childId} disabled>
Inaktives Kind – bitte Einteilung entfernen
</option>
);
})}
</select>
) : null}
{available.length > 0 ? (
<optgroup label="Verfügbar an diesem Tag">
{available.map(option)}
</optgroup>
) : null}
{unavailable.length > 0 ? (
<optgroup label="Keine Verfügbarkeit für diesen Tag">
{unavailable.map(option)}
</optgroup>
) : null}
</select>
{!day.isInvalid ? (
<p
className={`text-xs leading-5 ${(selectedChild && !selectedAvailable) || availableFamilies === 0 ? "text-[var(--warn)]" : "text-[var(--ink-soft)]"}`}
>
{selectedChild
? selectedAvailable
? "Eingeteilte Familie ist verfügbar."
: "Eingeteilte Familie hat für diesen Tag keine Verfügbarkeit angegeben."
: `${availableFamilies} ${availableFamilies === 1 ? "Familie ist" : "Familien sind"} verfügbar.`}
</p>
) : null}
</div>
{day.isInvalid ? (
<span className="text-xs text-[var(--danger)]">Einteilung nicht mehr gültig – bitte entfernen.</span>
<span className="text-xs text-[var(--danger)]">
Einteilung nicht mehr gültig – bitte entfernen.
</span>
) : null}
{!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">