806 lines
28 KiB
TypeScript
806 lines
28 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState, useTransition } from "react";
|
||
import Link from "next/link";
|
||
import { format } from "date-fns";
|
||
import { de } from "date-fns/locale";
|
||
import { NotdienstPlanStatus } from "@prisma/client";
|
||
import {
|
||
ArrowRight,
|
||
CheckSquare,
|
||
Download,
|
||
Loader2,
|
||
RotateCcw,
|
||
Send,
|
||
Wand2,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card } from "@/components/ui/card";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
generatePlanAction,
|
||
publishPlanAction,
|
||
updateAssignmentAction,
|
||
sendNotdienstRemindersAction,
|
||
resetPlanAction,
|
||
} from "./actions";
|
||
|
||
interface PlanViewProps {
|
||
planId?: string;
|
||
remindersOpen: boolean;
|
||
status?: NotdienstPlanStatus;
|
||
days: {
|
||
date: string;
|
||
assignmentId: string | null;
|
||
activeAlarmId?: string | null;
|
||
canAlarm?: boolean;
|
||
childId: string | null;
|
||
isOriginallyUnassigned?: boolean;
|
||
isInvalid?: boolean;
|
||
}[];
|
||
allChildren: {
|
||
id: string;
|
||
name: string;
|
||
parentName: string;
|
||
familyId: string;
|
||
}[];
|
||
familyStatuses: {
|
||
id: string;
|
||
name: string;
|
||
childCount: number;
|
||
requiredDays: number;
|
||
selectedDays: {
|
||
iso: string;
|
||
label: string;
|
||
}[];
|
||
}[];
|
||
requiredDays: number;
|
||
respondedFamilies: number;
|
||
openFamilies: number;
|
||
totalFamilies: number;
|
||
totalAvailabilitySlots: number;
|
||
coverageFactor: number;
|
||
closureCount: number;
|
||
monthName: string;
|
||
year: number;
|
||
month: number;
|
||
}
|
||
|
||
export function PlanView({
|
||
planId,
|
||
remindersOpen,
|
||
status,
|
||
days,
|
||
allChildren,
|
||
familyStatuses,
|
||
requiredDays,
|
||
respondedFamilies,
|
||
openFamilies,
|
||
totalFamilies,
|
||
totalAvailabilitySlots,
|
||
coverageFactor,
|
||
closureCount,
|
||
monthName,
|
||
year,
|
||
month,
|
||
}: PlanViewProps) {
|
||
const [isPending, startTransition] = useTransition();
|
||
const [activeTab, setActiveTab] = useState<"preview" | "manual">("preview");
|
||
const [resetDialogOpen, setResetDialogOpen] = useState(false);
|
||
|
||
const responsePercent =
|
||
totalFamilies > 0
|
||
? Math.round((respondedFamilies / totalFamilies) * 100)
|
||
: 0;
|
||
|
||
const handleGenerate = () => {
|
||
startTransition(async () => {
|
||
const result = await generatePlanAction(year, month);
|
||
if ("error" in result && result.error) {
|
||
toast.error(result.error as string);
|
||
} else {
|
||
toast.success("Plan erfolgreich generiert.");
|
||
}
|
||
});
|
||
};
|
||
|
||
const handlePublish = () => {
|
||
if (!planId) return;
|
||
startTransition(async () => {
|
||
const result = await publishPlanAction(planId);
|
||
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.",
|
||
);
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleSendReminders = () => {
|
||
startTransition(async () => {
|
||
const result = await sendNotdienstRemindersAction(year, month);
|
||
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.`,
|
||
);
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleReset = () => {
|
||
if (!planId) return;
|
||
startTransition(async () => {
|
||
const result = await resetPlanAction(planId);
|
||
if ("error" in result && result.error) {
|
||
toast.error(result.error as string);
|
||
} else {
|
||
toast.success("Plan wurde erfolgreich zurückgesetzt.");
|
||
setResetDialogOpen(false);
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleAssignmentChange = (date: string, newChildId: string) => {
|
||
if (!planId || (status !== "DRAFT" && status !== "PUBLISHED")) return;
|
||
const valueToSet = newChildId === "empty" ? null : newChildId;
|
||
|
||
startTransition(async () => {
|
||
const result = await updateAssignmentAction(
|
||
planId,
|
||
date.slice(0, 10),
|
||
valueToSet,
|
||
);
|
||
if ("error" in result && result.error) {
|
||
toast.error(result.error as string);
|
||
} else {
|
||
toast.success("Zuteilung aktualisiert.");
|
||
}
|
||
});
|
||
};
|
||
|
||
if (!planId && totalAvailabilitySlots === 0) {
|
||
return (
|
||
<Card className="border-dashed">
|
||
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
|
||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||
<Wand2 className="h-7 w-7 [stroke-width:1.7]" />
|
||
</div>
|
||
<h3 className="mb-1 text-xl font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||
Noch keine Verfügbarkeiten
|
||
</h3>
|
||
<p className="mb-5 max-w-md text-sm leading-6 text-[var(--ink-soft)]">
|
||
Für {monthName} hat noch keine Familie geantwortet. Verschicke
|
||
zuerst Erinnerungen, damit die automatische Planung belastbar ist.
|
||
</p>
|
||
<div className="flex flex-wrap justify-center gap-2">
|
||
<Button disabled={isPending} onClick={handleGenerate}>
|
||
Trotzdem generieren
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={handleSendReminders}
|
||
disabled={isPending || !remindersOpen}
|
||
>
|
||
{isPending ? (
|
||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||
) : (
|
||
<Send className="h-4 w-4 [stroke-width:1.7]" />
|
||
)}
|
||
Erinnerungen verschicken
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
if (!planId) {
|
||
return (
|
||
<div className="space-y-5">
|
||
<div className="grid grid-cols-[minmax(0,2fr)_minmax(300px,1fr)] gap-5 max-[1050px]:grid-cols-1">
|
||
<Card className="p-0">
|
||
<div className="border-b border-[var(--hairline-soft)] px-6 py-5">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||
Verfügbarkeiten Eltern
|
||
</h2>
|
||
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
|
||
{respondedFamilies} von {totalFamilies} Familien haben
|
||
vollständig geantwortet · noch {openFamilies} offen
|
||
</p>
|
||
</div>
|
||
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
|
||
{responsePercent}%
|
||
</span>
|
||
</div>
|
||
<div className="mt-4 h-1 overflow-hidden rounded-full bg-[var(--accent-soft)]">
|
||
<div
|
||
className="h-full rounded-full bg-[var(--accent)]"
|
||
style={{ width: `${responsePercent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||
{familyStatuses.map((family) => (
|
||
<div
|
||
key={family.id}
|
||
className="group relative flex items-center gap-3 px-6 py-3.5 transition-colors hover:bg-[var(--surface-2)]"
|
||
>
|
||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[11px] font-semibold uppercase text-[var(--accent-deep)]">
|
||
{family.name.slice(0, 1)}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-medium text-[var(--ink)]">
|
||
{family.name}
|
||
</p>
|
||
<p className="text-xs text-[var(--ink-muted)]">
|
||
{family.childCount} Kind
|
||
{family.childCount === 1 ? "" : "er"}
|
||
</p>
|
||
</div>
|
||
<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(" · ")
|
||
: "Noch keine Tage markiert"}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="border-t border-[var(--hairline-soft)] px-6 py-4">
|
||
<Button
|
||
variant="ghost"
|
||
onClick={handleSendReminders}
|
||
disabled={isPending || openFamilies === 0 || !remindersOpen}
|
||
>
|
||
{isPending ? (
|
||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||
) : (
|
||
<Send className="h-4 w-4 [stroke-width:1.7]" />
|
||
)}
|
||
Erinnerung an offene senden ({openFamilies})
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card className="p-6">
|
||
<div>
|
||
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||
Bedarfs-Übersicht
|
||
</h2>
|
||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||
Planungsgrundlage für {monthName}.
|
||
</p>
|
||
</div>
|
||
<div className="mt-5 grid gap-4">
|
||
<PlanMetric
|
||
label={`Tage benötigt in ${monthName}`}
|
||
value={requiredDays}
|
||
/>
|
||
<PlanMetric
|
||
label="Verfügbarkeiten gesamt"
|
||
value={totalAvailabilitySlots}
|
||
/>
|
||
<PlanMetric
|
||
label="Überdeckung"
|
||
value={`${coverageFactor.toFixed(1)}×`}
|
||
badge="good"
|
||
/>
|
||
<PlanMetric label="Schließtage" value={closureCount} />
|
||
</div>
|
||
<div className="mt-5 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] px-4 py-3 text-sm leading-6 text-[var(--ink-soft)]">
|
||
{coverageFactor >= 1
|
||
? "Du hast genug Verfügbarkeiten für einen Plan."
|
||
: "Die Verfügbarkeiten reichen noch nicht für einen belastbaren Plan."}
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card variant="featured" className="sticky bottom-6 z-10 p-5">
|
||
<div className="flex items-center justify-between gap-5 max-[760px]:flex-col max-[760px]:items-start">
|
||
<div className="flex gap-4">
|
||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
|
||
<Wand2 className="h-5 w-5 [stroke-width:1.7]" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
|
||
Plan automatisch generieren
|
||
</h3>
|
||
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
|
||
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)]">
|
||
Faire Verteilung berücksichtigt
|
||
</summary>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
<span>☑ Quote pro Familie</span>
|
||
<span>☑ Bevorzugte Tage</span>
|
||
<span>☐ Geschwister-Familien zusammen</span>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
<Button onClick={handleGenerate} disabled={isPending}>
|
||
{isPending && (
|
||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||
)}
|
||
Generieren
|
||
<ArrowRight className="h-4 w-4 [stroke-width:1.7]" />
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isDraft = status === "DRAFT";
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<div className="print:hidden flex flex-wrap items-center justify-between gap-3">
|
||
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
||
<TabButton
|
||
active={activeTab === "preview"}
|
||
onClick={() => setActiveTab("preview")}
|
||
>
|
||
Plan-Vorschau
|
||
</TabButton>
|
||
<TabButton
|
||
active={activeTab === "manual"}
|
||
onClick={() => setActiveTab("manual")}
|
||
withDivider
|
||
>
|
||
Manuell zuweisen
|
||
</TabButton>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
{isDraft && (
|
||
<Button onClick={handlePublish} disabled={isPending}>
|
||
{isPending && (
|
||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||
)}
|
||
<CheckSquare className="h-4 w-4 [stroke-width:1.7]" />
|
||
Plan veröffentlichen
|
||
</Button>
|
||
)}
|
||
<Button variant="ghost" onClick={() => window.print()}>
|
||
<Download className="h-4 w-4 [stroke-width:1.7]" />
|
||
Als PDF exportieren
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
className="text-[var(--danger)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]"
|
||
onClick={() => setResetDialogOpen(true)}
|
||
disabled={isPending}
|
||
>
|
||
<RotateCcw className="h-4 w-4 [stroke-width:1.7]" />
|
||
Plan zurücksetzen
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<Card className="print:hidden flex items-center justify-between p-4">
|
||
<div className="flex items-center gap-3">
|
||
<Badge variant={isDraft ? "warning" : "success"}>
|
||
{isDraft ? "Entwurf" : "Veröffentlicht"}
|
||
</Badge>
|
||
<span className="text-sm text-[var(--ink-soft)]">
|
||
{isDraft
|
||
? "Du kannst die Liste jetzt manuell anpassen."
|
||
: "Dieser Plan ist veröffentlicht und kann bei Änderungen nachbearbeitet werden."}
|
||
</span>
|
||
</div>
|
||
</Card>
|
||
|
||
{activeTab === "preview" ? (
|
||
<PlanPreviewTable days={days} allChildren={allChildren} />
|
||
) : (
|
||
<ManualAssignmentTable
|
||
days={days}
|
||
allChildren={allChildren}
|
||
familyStatuses={familyStatuses}
|
||
isDraft={isDraft}
|
||
isPending={isPending}
|
||
onAssignmentChange={handleAssignmentChange}
|
||
/>
|
||
)}
|
||
|
||
<Dialog open={resetDialogOpen} onOpenChange={setResetDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Plan zurücksetzen?</DialogTitle>
|
||
<DialogDescription>
|
||
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für
|
||
diesen Monat gehen unwiderruflich verloren.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
onClick={() => setResetDialogOpen(false)}
|
||
disabled={isPending}
|
||
>
|
||
Abbrechen
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="destructive"
|
||
onClick={handleReset}
|
||
disabled={isPending}
|
||
>
|
||
{isPending ? "Zurücksetzen..." : "Ja, Plan zurücksetzen"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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)]">
|
||
Noch nicht
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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)]"
|
||
}
|
||
>
|
||
{selectedDays} / {requiredDays} Tage
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function PlanMetric({
|
||
label,
|
||
value,
|
||
badge,
|
||
}: {
|
||
label: string;
|
||
value: number | string;
|
||
badge?: "good";
|
||
}) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-4 border-b border-[var(--hairline-soft)] pb-3 last:border-b-0 last:pb-0">
|
||
<span className="text-[13.5px] text-[var(--ink-soft)]">{label}</span>
|
||
{badge ? (
|
||
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-sm font-semibold text-[var(--good)] tabular-nums">
|
||
{value}
|
||
</span>
|
||
) : (
|
||
<span className="text-2xl font-medium tracking-[-0.025em] text-[var(--ink)] tabular-nums">
|
||
{value}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TabButton({
|
||
active,
|
||
withDivider,
|
||
onClick,
|
||
children,
|
||
}: {
|
||
active: boolean;
|
||
withDivider?: boolean;
|
||
onClick: () => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={[
|
||
"h-9 px-4 text-[13px] font-semibold transition focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]",
|
||
withDivider ? "border-l border-[var(--hairline-soft)]" : "",
|
||
active
|
||
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
|
||
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)]",
|
||
].join(" ")}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PlanPreviewTable({
|
||
days,
|
||
allChildren,
|
||
}: Pick<PlanViewProps, "days" | "allChildren">) {
|
||
return (
|
||
<Card className="overflow-hidden p-0">
|
||
<div className="grid grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] 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>Wochentag</div>
|
||
<div>Zugewiesen</div>
|
||
<div>Backup</div>
|
||
<div>Status</div>
|
||
</div>
|
||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||
{days.map((day) => {
|
||
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
||
const dateObj = new Date(yr, mo - 1, dy);
|
||
const assignedChild = allChildren.find(
|
||
(child) => child.id === day.childId,
|
||
);
|
||
return (
|
||
<div
|
||
key={day.date}
|
||
className="grid cursor-pointer grid-cols-[1.1fr_1fr_2fr_1.6fr_1fr] items-center p-4 text-sm transition-colors hover:bg-[var(--surface-2)]"
|
||
>
|
||
<div className="font-medium tabular-nums text-[var(--ink)]">
|
||
{format(dateObj, "dd.MM.", { locale: de })}
|
||
</div>
|
||
<div className="text-[var(--ink-soft)]">
|
||
{format(dateObj, "EEEE", { locale: de })}
|
||
</div>
|
||
<div className="text-[var(--ink)]">
|
||
{assignedChild
|
||
? assignedChild.parentName
|
||
: day.childId
|
||
? "Inaktives Kind"
|
||
: "Unbesetzt"}
|
||
{assignedChild && (
|
||
<span className="ml-1 text-[var(--ink-muted)]">
|
||
({assignedChild.name})
|
||
</span>
|
||
)}
|
||
{day.activeAlarmId ? (
|
||
<Link
|
||
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
|
||
href={`/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`}
|
||
>
|
||
Aktiven Alarm öffnen
|
||
</Link>
|
||
) : day.canAlarm && day.assignmentId ? (
|
||
<Link
|
||
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
|
||
href={`/dashboard/notdienst/alarm?assignment=${day.assignmentId}`}
|
||
>
|
||
Notdienstalarm auslösen
|
||
</Link>
|
||
) : null}
|
||
</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>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export function ManualAssignmentTable({
|
||
days,
|
||
allChildren,
|
||
familyStatuses,
|
||
isDraft,
|
||
isPending,
|
||
onAssignmentChange,
|
||
}: 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>();
|
||
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 (
|
||
<Card className="overflow-hidden p-0">
|
||
<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>
|
||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||
{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 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">
|
||
<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>
|
||
) : 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>
|
||
) : 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">
|
||
Veröffentlicht
|
||
</span>
|
||
)}
|
||
{day.assignmentId && day.canAlarm && (
|
||
<Link
|
||
className="text-xs font-semibold text-[var(--accent-deep)] underline underline-offset-4"
|
||
href={
|
||
day.activeAlarmId
|
||
? `/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`
|
||
: `/dashboard/notdienst/alarm?assignment=${day.assignmentId}`
|
||
}
|
||
>
|
||
{day.activeAlarmId
|
||
? "Aktiven Alarm öffnen"
|
||
: "Notdienstalarm auslösen"}
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|