545 lines
19 KiB
TypeScript
545 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useTransition } from "react";
|
||
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 {
|
||
generatePlanAction,
|
||
publishPlanAction,
|
||
updateAssignmentAction,
|
||
} from "./actions";
|
||
|
||
interface PlanViewProps {
|
||
planId?: string;
|
||
status?: NotdienstPlanStatus;
|
||
days: {
|
||
date: string;
|
||
assignmentId: string | null;
|
||
childId: string | null;
|
||
}[];
|
||
allChildren: {
|
||
id: string;
|
||
name: string;
|
||
parentName: string;
|
||
}[];
|
||
familyStatuses: {
|
||
id: string;
|
||
name: string;
|
||
childCount: number;
|
||
selectedDays: {
|
||
iso: string;
|
||
label: string;
|
||
}[];
|
||
}[];
|
||
requiredDays: number;
|
||
respondedFamilies: number;
|
||
openFamilies: number;
|
||
totalFamilies: number;
|
||
totalAvailabilitySlots: number;
|
||
coverageFactor: number;
|
||
closureCount: number;
|
||
monthName: string;
|
||
}
|
||
|
||
export function PlanView({
|
||
planId,
|
||
status,
|
||
days,
|
||
allChildren,
|
||
familyStatuses,
|
||
requiredDays,
|
||
respondedFamilies,
|
||
openFamilies,
|
||
totalFamilies,
|
||
totalAvailabilitySlots,
|
||
coverageFactor,
|
||
closureCount,
|
||
monthName,
|
||
}: PlanViewProps) {
|
||
const [isPending, startTransition] = useTransition();
|
||
const [activeTab, setActiveTab] = useState<"preview" | "manual">("preview");
|
||
|
||
const responsePercent =
|
||
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
|
||
|
||
const handleGenerate = () => {
|
||
startTransition(async () => {
|
||
const result = await generatePlanAction();
|
||
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 {
|
||
toast.success("Plan veröffentlicht! Er ist nun fix.");
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleAssignmentChange = (date: string, newChildId: string) => {
|
||
if (!planId || status !== "DRAFT") return;
|
||
const valueToSet = newChildId === "empty" ? null : newChildId;
|
||
|
||
startTransition(async () => {
|
||
const result = await updateAssignmentAction(planId, date, valueToSet);
|
||
if ("error" in result && result.error) {
|
||
toast.error(result.error as string);
|
||
} else {
|
||
toast.success("Zuteilung aktualisiert.");
|
||
}
|
||
});
|
||
};
|
||
|
||
if (!planId && respondedFamilies === 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">
|
||
<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
|
||
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} />
|
||
<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" disabled={openFamilies === 0}>
|
||
<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{" "}
|
||
{respondedFamilies} 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="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">
|
||
<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)]"
|
||
>
|
||
<RotateCcw className="h-4 w-4 [stroke-width:1.7]" />
|
||
Plan zurücksetzen
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<Card className="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 verbindlich."}
|
||
</span>
|
||
</div>
|
||
</Card>
|
||
|
||
{activeTab === "preview" ? (
|
||
<PlanPreviewTable days={days} allChildren={allChildren} />
|
||
) : (
|
||
<ManualAssignmentTable
|
||
days={days}
|
||
allChildren={allChildren}
|
||
isDraft={isDraft}
|
||
isPending={isPending}
|
||
onAssignmentChange={handleAssignmentChange}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FamilyStatusPill({ selectedDays }: { selectedDays: 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="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]">
|
||
{selectedDays} 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 dateObj = new Date(day.date);
|
||
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 : "Unbesetzt"}
|
||
{assignedChild && (
|
||
<span className="ml-1 text-[var(--ink-muted)]">
|
||
({assignedChild.name})
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
||
<div>
|
||
<Badge variant={assignedChild ? "success" : "destructive"}>
|
||
{assignedChild ? "Besetzt" : "Offen"}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function ManualAssignmentTable({
|
||
days,
|
||
allChildren,
|
||
isDraft,
|
||
isPending,
|
||
onAssignmentChange,
|
||
}: Pick<PlanViewProps, "days" | "allChildren"> & {
|
||
isDraft: boolean;
|
||
isPending: boolean;
|
||
onAssignmentChange: (date: string, newChildId: string) => void;
|
||
}) {
|
||
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>Datum</div>
|
||
<div>Eingeteilte Familie</div>
|
||
</div>
|
||
<div className="divide-y divide-[var(--hairline-soft)]">
|
||
{days.map((day) => {
|
||
const dateObj = new Date(day.date);
|
||
const assignedChild = allChildren.find(
|
||
(child) => child.id === day.childId,
|
||
);
|
||
|
||
return (
|
||
<div
|
||
key={day.date}
|
||
className="grid 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>
|
||
{isDraft ? (
|
||
<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}
|
||
onChange={(event) =>
|
||
onAssignmentChange(day.date, event.target.value)
|
||
}
|
||
>
|
||
<option value="empty">-- Unbesetzt --</option>
|
||
{allChildren.map((child) => (
|
||
<option key={child.id} value={child.id}>
|
||
{child.parentName} ({child.name})
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : assignedChild ? (
|
||
<span className="text-sm text-[var(--ink)]">
|
||
{assignedChild.parentName}{" "}
|
||
<span className="text-[var(--ink-muted)]">
|
||
({assignedChild.name})
|
||
</span>
|
||
</span>
|
||
) : (
|
||
<span className="text-sm font-medium text-[var(--danger)]">
|
||
Unbesetzt!
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|