Apply linen design system across app

This commit is contained in:
t.indorf
2026-05-20 22:47:52 +02:00
parent 99ff978116
commit 1c5d2f10ac
56 changed files with 4698 additions and 2159 deletions
+454 -96
View File
@@ -1,14 +1,23 @@
"use client";
import { useTransition } from "react";
import { useState, useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
import { Loader2, Wand2, CheckSquare } from "lucide-react";
import {
ArrowRight,
CheckSquare,
Download,
Loader2,
RotateCcw,
Send,
Wand2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
generatePlanAction,
publishPlanAction,
@@ -19,7 +28,7 @@ interface PlanViewProps {
planId?: string;
status?: NotdienstPlanStatus;
days: {
date: string; // ISO
date: string;
assignmentId: string | null;
childId: string | null;
}[];
@@ -28,10 +37,45 @@ interface PlanViewProps {
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 }: PlanViewProps) {
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 () => {
@@ -70,21 +114,166 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
});
};
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="flex flex-col items-center justify-center rounded-lg border border-dashed bg-card p-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Wand2 className="h-6 w-6" />
<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>
<h3 className="mb-1 text-lg font-semibold">Noch kein Plan vorhanden</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
Generiere den Plan basierend auf den Verfügbarkeiten der Eltern
automatisch.
</p>
<Button onClick={handleGenerate} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Plan automatisch generieren
</Button>
<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>
);
}
@@ -92,95 +281,264 @@ export function PlanView({ planId, status, days, allChildren }: PlanViewProps) {
const isDraft = status === "DRAFT";
return (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<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-muted-foreground">
<span className="text-sm text-[var(--ink-soft)]">
{isDraft
? "Du kannst die Liste jetzt manuell anpassen."
: "Dieser Plan ist verbindlich."}
</span>
</div>
</Card>
{isDraft && (
<Button onClick={handlePublish} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<CheckSquare className="mr-2 h-4 w-4" />
Plan veröffentlichen
</Button>
)}
</div>
<div className="rounded-lg border bg-card">
<div className="grid grid-cols-[1fr_2fr] border-b bg-muted/30 p-4 text-sm font-medium">
<div>Datum</div>
<div>Eingeteilte Familie</div>
</div>
<div className="divide-y">
{days.map((day) => {
const dateObj = new Date(day.date);
const isUnbesetzt = !day.childId;
return (
<div
key={day.date}
className="grid grid-cols-[1fr_2fr] items-center p-4 transition-colors hover:bg-muted/10"
>
<div className="font-medium">
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div>
{isDraft ? (
<select
className="w-full max-w-sm rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={day.childId || "empty"}
disabled={isPending}
onChange={(e) =>
handleAssignmentChange(day.date, e.target.value)
}
>
<option value="empty" className="text-destructive">
-- Unbesetzt --
</option>
{allChildren.map((c) => (
<option key={c.id} value={c.id}>
{c.parentName} ({c.name})
</option>
))}
</select>
) : (
<div>
{isUnbesetzt ? (
<span className="text-sm font-medium text-destructive">
Unbesetzt!
</span>
) : (
<span className="text-sm">
{
allChildren.find((c) => c.id === day.childId)
?.parentName
}{" "}
<span className="text-muted-foreground">
(
{
allChildren.find((c) => c.id === day.childId)
?.name
}
)
</span>
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{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>
);
}