added button to publish all drafts at once

This commit is contained in:
Rajpreet0 2026-09-15 13:32:13 +02:00
parent 1866d70691
commit a26e2a6245
4 changed files with 1072 additions and 1 deletions

View file

@ -0,0 +1,281 @@
/**
* Publish-Drafts Actions — Kurs-Entwürfe gesammelt veröffentlichen
*
* Veröffentlichen ist kein reiner Status-Flip: publishedCourseSchema verlangt
* Pflichtfelder (Name, Sportart, Datum, Zeiten, Raum, Teilnehmer, Preis), die
* ein Entwurf leer lassen darf. Ein blindes updateMany würde also unvollständige
* Kurse öffentlich buchbar machen. Beide Actions hier validieren deshalb jeden
* Kurs einzeln gegen dasselbe Schema wie updateCourse.
*/
"use server";
import { prisma } from "@/src/lib/prisma";
import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
import { publishedCourseSchema } from "../schemas/course-schema";
import { revalidatePath } from "next/cache";
import { Prisma } from "@/src/generated/prisma/client";
/** Ein Entwurf in der Vorschau, inkl. Ergebnis der Veröffentlichbarkeits-Prüfung. */
export type DraftCandidate = {
id: string;
name: string;
date: Date;
timeFrom: string;
timeTo: string;
room: string;
price: number;
maxParticipants: number;
sport: string[];
/** true, wenn der Kurs Teil eines Dauerauftrags ist (für das Serien-Badge). */
isSeriesInstance: boolean;
/** Leeres Array = veröffentlichbar. Sonst die fehlenden Pflichtfelder in Klartext. */
missingFields: string[];
};
/** Feldname → deutsches Label für die "fehlt:"-Anzeige im Dialog. */
const FIELD_LABELS: Record<string, string> = {
name: "Name",
sport: "Sportart",
date: "Datum",
timeFrom: "Anfangszeit",
timeTo: "Endzeit",
trainers: "Trainer",
room: "Raum",
maxParticipants: "Teilnehmerzahl",
price: "Preis",
frequency: "Häufigkeit",
endDate: "Enddatum",
weekdays: "Wochentage",
};
/**
* Date → "yyyy-mm-dd" aus den LOKALEN Komponenten, nicht via toISOString():
* letzteres rechnet nach UTC um und verschiebt ein Datum je nach Zeitzone um
* einen Tag, was bei der Datums-Validierung (z.B. endDate > date) zu falschen
* Ergebnissen führt. Gleiche Umwandlung wie im Kursformular (course-create-view).
*/
function toDateInput(date: Date): string {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-");
}
/** Prisma-Kurs → das Objekt, das publishedCourseSchema erwartet (ISO-Datum statt Date). */
function toFormData(course: {
name: string;
sport: string[];
level: string;
date: Date;
timeFrom: string;
timeTo: string;
trainers: string[];
room: string;
description: string;
coverImage: string | null;
maxParticipants: number;
price: number;
isStandingOrder: boolean;
frequency: string | null;
weekdays: string[];
endDate: Date | null;
}) {
return {
name: course.name,
sport: course.sport,
level: course.level,
date: toDateInput(course.date),
timeFrom: course.timeFrom,
timeTo: course.timeTo,
trainers: course.trainers,
room: course.room,
description: course.description,
coverImage: course.coverImage ?? undefined,
maxParticipants: course.maxParticipants,
price: course.price,
isStandingOrder: course.isStandingOrder,
frequency: course.frequency ?? undefined,
weekdays: course.weekdays,
endDate: course.endDate ? toDateInput(course.endDate) : undefined,
};
}
/**
* Prüft einen Kurs gegen publishedCourseSchema und gibt die fehlenden Felder
* als deutsche Labels zurück. Leeres Array = veröffentlichbar.
*/
function getMissingFields(course: Parameters<typeof toFormData>[0]): string[] {
const validation = publishedCourseSchema.safeParse(toFormData(course));
if (validation.success) return [];
const fieldErrors = validation.error.flatten().fieldErrors;
const missing = Object.keys(fieldErrors).map((field) => FIELD_LABELS[field] ?? field);
// Refinements ohne path (z.B. Zeit-Vergleiche) landen in formErrors und
// würden sonst als "veröffentlichbar" durchgehen, obwohl das Schema ablehnt.
if (missing.length === 0) {
const formErrors = validation.error.flatten().formErrors;
return formErrors.length > 0 ? formErrors : ["Unvollständig"];
}
return Array.from(new Set(missing));
}
/** Gemeinsame Auth-Prüfung: Nur Manager/Employees mit edit-Recht dürfen veröffentlichen. */
async function authorize() {
const userData = await getUserData();
if (!userData) {
return { ok: false as const, error: "Sie müssen angemeldet sein" };
}
const canEdit =
isManager(userData) || (isEmployee(userData) && userData.permissions.courses.edit);
if (!canEdit) {
return { ok: false as const, error: "Keine Berechtigung zum Veröffentlichen von Kursen" };
}
const managerId = getEffectiveManagerId(userData);
if (!managerId) {
return { ok: false as const, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
}
return { ok: true as const, managerId };
}
/**
* Lädt alle Entwürfe des Managers im angegebenen Zeitraum und markiert je Kurs,
* ob er veröffentlicht werden kann. Dauerauftrag-Templates bleiben außen vor —
* genau wie in getMyCourses zählen nur die sichtbaren Instanzen.
*
* @param options - Zeitraum-Grenzen (beide inklusive, beide optional)
*/
export async function getPublishableDrafts(options?: { dateFrom?: Date; dateTo?: Date }) {
try {
const auth = await authorize();
if (!auth.ok) return { success: false as const, error: auth.error, drafts: [] };
const whereClause: Prisma.CourseWhereInput = {
createdBy: auth.managerId,
status: "draft",
isStandingOrder: false,
};
if (options?.dateFrom || options?.dateTo) {
whereClause.date = {};
if (options.dateFrom) whereClause.date.gte = options.dateFrom;
if (options.dateTo) whereClause.date.lte = options.dateTo;
}
const courses = await prisma.course.findMany({
where: whereClause,
orderBy: { date: "asc" },
});
const drafts: DraftCandidate[] = courses.map((course) => ({
id: course.id,
name: course.name,
date: course.date,
timeFrom: course.timeFrom,
timeTo: course.timeTo,
room: course.room,
price: course.price,
maxParticipants: course.maxParticipants,
sport: course.sport,
isSeriesInstance: course.parentCourseId !== null,
missingFields: getMissingFields(course),
}));
return { success: true as const, drafts };
} catch (error) {
console.error("Error fetching publishable drafts:", error);
return { success: false as const, error: "Fehler beim Laden der Entwürfe", drafts: [] };
}
}
/**
* Veröffentlicht die übergebenen Entwürfe.
*
* Die IDs kommen aus dem Client und werden deshalb serverseitig komplett neu
* geprüft: Besitz (createdBy), Status (nur "draft" — ein bereits abgesagter Kurs
* darf nicht über diesen Weg wiederbelebt werden) und Vollständigkeit. Kurse,
* die durchfallen, werden übersprungen statt den ganzen Vorgang abzubrechen —
* der Nutzer bekommt sie im Ergebnis namentlich zurückgemeldet.
*
* @param courseIds - IDs der zu veröffentlichenden Entwürfe
*/
export async function publishDraftCourses(courseIds: string[]) {
try {
const auth = await authorize();
if (!auth.ok) return { success: false as const, error: auth.error };
if (courseIds.length === 0) {
return { success: false as const, error: "Keine Kurse ausgewählt" };
}
// Nur eigene Entwürfe — fremde oder bereits veröffentlichte IDs fallen hier raus.
const courses = await prisma.course.findMany({
where: {
id: { in: courseIds },
createdBy: auth.managerId,
status: "draft",
isStandingOrder: false,
},
});
const publishableIds: string[] = [];
const skipped: { name: string; reason: string }[] = [];
for (const course of courses) {
const missing = getMissingFields(course);
if (missing.length === 0) {
publishableIds.push(course.id);
} else {
skipped.push({
name: course.name || "Unbenannter Entwurf",
reason: `fehlt: ${missing.join(", ")}`,
});
}
}
// IDs, die die DB-Abfrage gar nicht zurückgegeben hat (gelöscht, fremd oder
// inzwischen nicht mehr "draft"), sind ebenfalls übersprungen.
const foundIds = new Set(courses.map((c) => c.id));
const vanishedCount = courseIds.filter((id) => !foundIds.has(id)).length;
let publishedIds: string[] = [];
if (publishableIds.length > 0) {
// In einer Transaktion, damit das nachgelagerte findMany nur die Zeilen
// sieht, die dieses updateMany erfasst hat. status erneut im where:
// zwischen findMany und updateMany kann der Kurs in einem parallelen
// Request veröffentlicht oder abgesagt worden sein.
publishedIds = await prisma.$transaction(async (tx) => {
await tx.course.updateMany({
where: { id: { in: publishableIds }, createdBy: auth.managerId, status: "draft" },
data: { status: "published" },
});
const updated = await tx.course.findMany({
where: { id: { in: publishableIds }, createdBy: auth.managerId, status: "published" },
select: { id: true },
});
return updated.map((c) => c.id);
});
}
revalidatePath("/courses");
return {
success: true as const,
published: publishedIds.length,
publishedIds,
skipped,
vanished: vanishedCount,
};
} catch (error) {
console.error("Error publishing draft courses:", error);
return { success: false as const, error: "Fehler beim Veröffentlichen der Entwürfe" };
}
}

View file

@ -0,0 +1,455 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { AlertTriangle, ChevronLeft, ChevronRight, Pen, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/src/components/ui/button";
import { Checkbox } from "@/src/components/ui/checkbox";
import { Input } from "@/src/components/ui/input";
import { Label } from "@/src/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/src/components/ui/dialog";
import {
getPublishableDrafts,
publishDraftCourses,
type DraftCandidate,
} from "../../actions/publish-drafts-actions";
const PAGE_SIZE = 8;
interface PublishDraftsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Vorbelegung der Zeitraum-Felder (ISO yyyy-mm-dd), i.d.R. aus dem Listenfilter. */
defaultDateFrom: string;
defaultDateTo: string;
/** Wird nach erfolgreichem Veröffentlichen aufgerufen, um die Liste zu aktualisieren. */
onPublished: (publishedIds: string[]) => void;
}
/**
* Das Jahr wird mitgeschrieben, weil der Zeitraum ohne "bis"-Grenze über
* Jahreswechsel hinausreichen kann — "Mi., 06.01." wäre sonst mehrdeutig.
*/
const formatDate = (date: Date) =>
new Date(date).toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "2-digit",
});
const formatEuro = (price: number) =>
price.toLocaleString("de-DE", { style: "currency", currency: "EUR" });
/**
* "yyyy-mm-dd" → lokaler Tagesanfang bzw. -ende. new Date("2026-10-01") würde
* als UTC-Mitternacht geparst; der Kurs-Vergleich in der DB läuft aber gegen
* lokale Zeitstempel, wodurch je nach Zeitzone der Rand-Tag herausfiele.
*/
const parseLocalDate = (value: string, endOfDay: boolean) => {
const [year, month, day] = value.split("-").map(Number);
return endOfDay
? new Date(year, month - 1, day, 23, 59, 59, 999)
: new Date(year, month - 1, day, 0, 0, 0, 0);
};
const startOfLocalDay = (value: string) => parseLocalDate(value, false);
const endOfLocalDay = (value: string) => parseLocalDate(value, true);
/**
* Wird pro Öffnen frisch gemounted (key am Aufrufer), damit Auswahl, Suche und
* Seite bei jedem Öffnen zurückgesetzt sind — dasselbe Muster wie in
* CancelCourseConfirmDialog.
*/
function PublishDraftsDialogBody({
defaultDateFrom,
defaultDateTo,
onClose,
onPublished,
}: {
defaultDateFrom: string;
defaultDateTo: string;
onClose: () => void;
onPublished: (publishedIds: string[]) => void;
}) {
const router = useRouter();
const [dateFrom, setDateFrom] = useState(defaultDateFrom);
const [dateTo, setDateTo] = useState(defaultDateTo);
const [drafts, setDrafts] = useState<DraftCandidate[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isPending, setIsPending] = useState(false);
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Entwürfe bei Öffnen und nach jeder Zeitraum-Änderung neu laden. Die Auswahl
// wird dabei bewusst zurückgesetzt: ein Kurs außerhalb des neuen Zeitraums
// wäre sonst unsichtbar ausgewählt und würde beim Veröffentlichen überraschen.
useEffect(() => {
let cancelled = false;
(async () => {
setIsLoading(true);
try {
const result = await getPublishableDrafts({
dateFrom: dateFrom ? startOfLocalDay(dateFrom) : undefined,
dateTo: dateTo ? endOfLocalDay(dateTo) : undefined,
});
if (cancelled) return;
if (result.success) {
setDrafts(result.drafts);
// Standardmäßig ist alles Veröffentlichbare vorausgewählt — der
// häufigste Fall ist "alle live stellen".
setSelectedIds(
new Set(result.drafts.filter((d) => d.missingFields.length === 0).map((d) => d.id))
);
setPage(0);
} else {
toast.error(result.error);
}
} catch {
if (!cancelled) toast.error("Entwürfe konnten nicht geladen werden.");
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [dateFrom, dateTo]);
const readyDrafts = useMemo(() => drafts.filter((d) => d.missingFields.length === 0), [drafts]);
const incompleteDrafts = useMemo(
() => drafts.filter((d) => d.missingFields.length > 0),
[drafts]
);
const filteredReady = useMemo(() => {
const term = search.trim().toLowerCase();
if (!term) return readyDrafts;
return readyDrafts.filter(
(d) =>
d.name.toLowerCase().includes(term) ||
d.room.toLowerCase().includes(term) ||
d.sport.some((s) => s.toLowerCase().includes(term))
);
}, [readyDrafts, search]);
const pageCount = Math.max(1, Math.ceil(filteredReady.length / PAGE_SIZE));
// Nach dem Tippen in der Suche kann die aktuelle Seite hinter dem Ende liegen.
const safePage = Math.min(page, pageCount - 1);
const pageItems = filteredReady.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE);
const toggleOne = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
// "Alle auswählen" bezieht sich auf die aktuelle Suchtreffermenge, nicht nur
// auf die sichtbare Seite — sonst müsste man sich durch alle Seiten klicken.
const allFilteredSelected =
filteredReady.length > 0 && filteredReady.every((d) => selectedIds.has(d.id));
const toggleAllFiltered = () => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (allFilteredSelected) {
filteredReady.forEach((d) => next.delete(d.id));
} else {
filteredReady.forEach((d) => next.add(d.id));
}
return next;
});
};
const handlePublish = async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
setIsPending(true);
try {
const result = await publishDraftCourses(ids);
if (!result.success) {
toast.error(result.error);
return;
}
if (result.published === 0) {
toast.error("Es wurde kein Kurs veröffentlicht.");
return;
}
const skippedCount = result.skipped.length + result.vanished;
if (skippedCount > 0) {
toast.warning(
`${result.published} Kurs(e) veröffentlicht, ${skippedCount} übersprungen: ` +
result.skipped
.slice(0, 3)
.map((s) => `${s.name} (${s.reason})`)
.join("; ")
);
} else {
toast.success(
result.published === 1
? "Kurs veröffentlicht."
: `${result.published} Kurse veröffentlicht.`
);
}
onPublished(result.publishedIds);
onClose();
} catch {
toast.error("Kurse konnten nicht veröffentlicht werden.");
} finally {
setIsPending(false);
}
};
return (
<>
<DialogHeader>
<DialogTitle>Entwürfe veröffentlichen</DialogTitle>
<DialogDescription>
Ausgewählte Entwürfe werden sofort öffentlich buchbar. Kurse mit fehlenden Pflichtfeldern
können nicht veröffentlicht werden.
</DialogDescription>
</DialogHeader>
{/* Zeitraum */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="publish-date-from" className="text-xs text-gray-500">
Zeitraum von
</Label>
<Input
id="publish-date-from"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
disabled={isPending}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="publish-date-to" className="text-xs text-gray-500">
bis <span className="text-gray-400">(leer = alle)</span>
</Label>
<Input
id="publish-date-to"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
disabled={isPending}
/>
</div>
</div>
{isLoading ? (
<p className="py-8 text-center text-sm text-gray-500">Entwürfe werden geladen…</p>
) : drafts.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-500">
In diesem Zeitraum gibt es keine Entwürfe.
</p>
) : (
<>
{/* Suche */}
{readyDrafts.length > PAGE_SIZE && (
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
/>
<Input
placeholder="Nach Name, Raum oder Sportart suchen…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
disabled={isPending}
className="pl-9"
/>
</div>
)}
{/* Veröffentlichbare Entwürfe */}
{readyDrafts.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2.5 pb-2 border-b border-gray-200">
<Checkbox
id="publish-select-all"
checked={allFilteredSelected}
onCheckedChange={toggleAllFiltered}
disabled={isPending || filteredReady.length === 0}
/>
<Label
htmlFor="publish-select-all"
className="text-sm font-medium cursor-pointer select-none"
>
Alle auswählen ({filteredReady.length})
</Label>
</div>
{pageItems.length === 0 ? (
<p className="py-6 text-center text-sm text-gray-500">Keine Treffer.</p>
) : (
<ul className="divide-y divide-gray-100">
{pageItems.map((draft) => (
<li key={draft.id} className="flex items-center gap-2.5 py-2.5">
<Checkbox
id={`publish-${draft.id}`}
checked={selectedIds.has(draft.id)}
onCheckedChange={() => toggleOne(draft.id)}
disabled={isPending}
/>
<Label
htmlFor={`publish-${draft.id}`}
className="flex flex-1 min-w-0 items-center gap-2 cursor-pointer font-normal"
>
<span className="flex-1 min-w-0 truncate text-sm font-medium text-gray-900">
{draft.name}
</span>
{draft.isSeriesInstance && (
<span className="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-blue-100 text-blue-700">
Serie
</span>
)}
<span className="shrink-0 text-xs text-gray-500 tabular-nums">
{formatDate(draft.date)} · {draft.timeFrom}
</span>
<span className="shrink-0 text-xs font-semibold text-gray-700 tabular-nums">
{formatEuro(draft.price)}
</span>
</Label>
</li>
))}
</ul>
)}
{/* Pagination */}
{pageCount > 1 && (
<div className="flex items-center justify-between pt-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setPage(safePage - 1)}
disabled={safePage === 0 || isPending}
>
<ChevronLeft size={16} />
Zurück
</Button>
<span className="text-xs text-gray-500 tabular-nums">
Seite {safePage + 1} von {pageCount}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setPage(safePage + 1)}
disabled={safePage >= pageCount - 1 || isPending}
>
Weiter
<ChevronRight size={16} />
</Button>
</div>
)}
</div>
)}
{/* Unvollständige Entwürfe — sichtbar, aber nicht wählbar */}
{incompleteDrafts.length > 0 && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3 space-y-2">
<div className="flex items-center gap-2 text-sm text-yellow-800">
<AlertTriangle size={16} className="shrink-0" />
<span className="font-medium">
{incompleteDrafts.length}{" "}
{incompleteDrafts.length === 1 ? "Entwurf ist" : "Entwürfe sind"} unvollständig
</span>
</div>
<ul className="space-y-1.5">
{incompleteDrafts.map((draft) => (
<li key={draft.id} className="flex items-center gap-2 text-xs">
<span className="flex-1 min-w-0 truncate text-yellow-900">
{draft.name || "Unbenannter Entwurf"}
<span className="text-yellow-700">
{" "}
— fehlt: {draft.missingFields.join(", ")}
</span>
</span>
<button
type="button"
onClick={() => router.push(`/courses/edit/${draft.id}`)}
className="shrink-0 p-1 rounded hover:bg-yellow-100 transition"
title="Entwurf bearbeiten"
>
<Pen size={14} className="text-yellow-800" />
</button>
</li>
))}
</ul>
</div>
)}
</>
)}
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={isPending}>
Abbrechen
</Button>
<Button onClick={handlePublish} disabled={isPending || isLoading || selectedIds.size === 0}>
{isPending
? "Wird veröffentlicht…"
: selectedIds.size === 1
? "1 Kurs veröffentlichen"
: `${selectedIds.size} Kurse veröffentlichen`}
</Button>
</DialogFooter>
</>
);
}
const PublishDraftsDialog = ({
open,
onOpenChange,
defaultDateFrom,
defaultDateTo,
onPublished,
}: PublishDraftsDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
{open && (
<PublishDraftsDialogBody
// Ohne key behält der Body seinen State über das Schließen hinaus,
// und die useState-Vorbelegung der Datumsfelder greift nur beim
// allerersten Öffnen — beim zweiten Öffnen stünde dort der zuletzt
// getippte Zeitraum statt der aktuellen Vorbelegung.
key={`${defaultDateFrom}-${defaultDateTo}`}
defaultDateFrom={defaultDateFrom}
defaultDateTo={defaultDateTo}
onClose={() => onOpenChange(false)}
onPublished={onPublished}
/>
)}
</DialogContent>
</Dialog>
);
};
export default PublishDraftsDialog;

View file

@ -8,11 +8,12 @@ import { Button } from "@/src/components/ui/button";
import { toast } from "sonner";
import { useRouter, useSearchParams } from "next/navigation";
import CourseListItem from "../components/CourseListItem";
import { Filter } from "lucide-react";
import { Filter, Upload } from "lucide-react";
import DeleteDialog from "@/src/components/layout/DeleteDialog";
import CourseFilter from "../components/CourseFilter";
import { StandingOrderScopeDialog } from "../components/StandingOrderScopeDialog";
import CancelCourseConfirmDialog from "../components/CancelCourseConfirmDialog";
import PublishDraftsDialog from "../components/PublishDraftsDialog";
import { useCancelCourse } from "../../hooks/useCancelCourse";
type Course = {
@ -83,6 +84,7 @@ const CourseListView = ({
const [priceMin, setPriceMin] = useState<number | null>(null);
const [priceMax, setPriceMax] = useState<number | null>(null);
const [deleteMode, setDeleteMode] = useState(false);
const [publishDialogOpen, setPublishDialogOpen] = useState(false);
const {
deleteDialogOpen,
@ -276,6 +278,30 @@ const CourseListView = ({
}));
const uniqueRooms = Array.from(new Set(courses.map((c) => roomsMap[c.room] || c.room)));
// Lokale Datumskomponenten statt toISOString(), das nach UTC umrechnet und
// das Datum je nach Zeitzone um einen Tag verschieben würde.
const toInputDate = (date: Date) => {
const d = new Date(date);
return [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, "0"),
String(d.getDate()).padStart(2, "0"),
].join("-");
};
// Steuert nur, OB der Veröffentlichen-Button erscheint. Bewusst ohne Zahl am
// Button: hier sind nur die geladenen Kurse bekannt, der Dialog zeigt aber
// alle kommenden Entwürfe — eine Zahl hier würde der Zahl dort widersprechen.
const hasDrafts = courses.some((c) => c.status === "draft" && !isPastCourse(c.date));
// Der Dialog übernimmt den Datumsfilter des Nutzers, falls gesetzt. Ohne
// Filter bleibt "bis" leer = keine obere Grenze, es werden also alle
// kommenden Entwürfe gezeigt. Bewusst NICHT loadedDateTo: das ist der
// Ladezustand der Liste (wächst mit jedem "Mehr laden") und würde als
// Zeitraum-Grenze willkürlich Termine abschneiden.
const publishDefaultFrom = dateFrom || toInputDate(new Date());
const publishDefaultTo = dateTo;
const activeFiltersCount = [
filterStatus !== "all",
filterSport !== "all",
@ -412,6 +438,16 @@ const CourseListView = ({
)}
</Button>
</CourseFilter>
{canEdit && hasDrafts && (
<Button
variant="outline"
onClick={() => setPublishDialogOpen(true)}
className="flex items-center gap-2"
>
<Upload size={18} />
Entwürfe veröffentlichen
</Button>
)}
{canDelete && (
<Button
variant={deleteMode ? "destructive" : "outline"}
@ -530,6 +566,20 @@ const CourseListView = ({
action="delete"
/>
<PublishDraftsDialog
open={publishDialogOpen}
onOpenChange={setPublishDialogOpen}
defaultDateFrom={publishDefaultFrom}
defaultDateTo={publishDefaultTo}
onPublished={(publishedIds) => {
const ids = new Set(publishedIds);
setCourses((prev) =>
prev.map((c) => (ids.has(c.id) ? { ...c, status: "published" } : c))
);
router.refresh();
}}
/>
{courseToCancel && (
<CancelCourseConfirmDialog
open={cancelDialogOpen}

View file

@ -0,0 +1,285 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({
prisma: {
course: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(),
},
}));
vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isManager: (u: { role: string }) => u?.role === "manager",
isEmployee: (u: { role: string }) => u?.role === "employee",
getEffectiveManagerId: (u: { role: string; id: string; managerId?: string }) =>
u?.role === "manager" ? u.id : (u?.managerId ?? null),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
import {
getPublishableDrafts,
publishDraftCourses,
} from "@/src/modules/courses/actions/publish-drafts-actions";
const findMany = vi.mocked(prisma.course.findMany);
const updateMany = vi.mocked(prisma.course.updateMany);
const transaction = vi.mocked(prisma.$transaction);
const mockGetUserData = vi.mocked(getUserData);
const MANAGER = { id: "mgr-1", role: "manager" };
/** Ein vollständiger, veröffentlichbarer Entwurf. Einzelne Felder pro Test überschreibbar. */
const completeDraft = (overrides: Record<string, unknown> = {}) => ({
id: "c1",
name: "Yoga Basics",
sport: ["yoga"],
level: "any",
date: new Date(2026, 9, 2),
timeFrom: "18:00",
timeTo: "19:00",
trainers: ["t1"],
room: "room-1",
description: "",
coverImage: null,
maxParticipants: 12,
price: 15,
isStandingOrder: false,
frequency: null,
weekdays: [],
endDate: null,
parentCourseId: null,
status: "draft",
createdBy: "mgr-1",
...overrides,
});
/** $transaction mit Callback ausführen, als liefe sie gegen die echte DB. */
const runTransaction = (updatedIds: string[]) => {
transaction.mockImplementationOnce(async (cb: unknown) => {
const tx = {
course: {
updateMany: vi.fn().mockResolvedValue({ count: updatedIds.length }),
findMany: vi.fn().mockResolvedValue(updatedIds.map((id) => ({ id }))),
},
};
return (cb as (t: typeof tx) => Promise<string[]>)(tx);
});
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("getPublishableDrafts", () => {
it("lehnt nicht angemeldete Nutzer ab", async () => {
mockGetUserData.mockResolvedValueOnce(null as never);
const result = await getPublishableDrafts();
expect(result.success).toBe(false);
expect(findMany).not.toHaveBeenCalled();
});
it("lehnt Employees ohne edit-Berechtigung ab", async () => {
mockGetUserData.mockResolvedValueOnce({
id: "emp-1",
role: "employee",
managerId: "mgr-1",
permissions: { courses: { edit: false } },
} as never);
const result = await getPublishableDrafts();
expect(result.success).toBe(false);
expect(findMany).not.toHaveBeenCalled();
});
it("fragt nur eigene Entwurf-Instanzen ab, keine Dauerauftrag-Templates", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([] as never);
await getPublishableDrafts({
dateFrom: new Date(2026, 9, 1),
dateTo: new Date(2026, 9, 31),
});
expect(findMany.mock.calls[0][0]?.where).toEqual({
createdBy: "mgr-1",
status: "draft",
isStandingOrder: false,
date: { gte: new Date(2026, 9, 1), lte: new Date(2026, 9, 31) },
});
});
it("setzt keine Datums-Bedingung, wenn kein Zeitraum übergeben wird", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([] as never);
await getPublishableDrafts();
expect(findMany.mock.calls[0][0]?.where).toEqual({
createdBy: "mgr-1",
status: "draft",
isStandingOrder: false,
});
});
it("lässt die obere Grenze offen, wenn nur dateFrom gesetzt ist", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([] as never);
await getPublishableDrafts({ dateFrom: new Date(2026, 8, 15) });
expect(findMany.mock.calls[0][0]?.where).toEqual({
createdBy: "mgr-1",
status: "draft",
isStandingOrder: false,
date: { gte: new Date(2026, 8, 15) },
});
});
it("markiert vollständige Entwürfe als veröffentlichbar", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
const result = await getPublishableDrafts();
expect(result.success).toBe(true);
expect(result.drafts[0].missingFields).toEqual([]);
});
it("benennt fehlende Pflichtfelder auf Deutsch", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([
completeDraft({ name: "", room: "", price: undefined }),
] as never);
const result = await getPublishableDrafts();
expect(result.drafts[0].missingFields).toContain("Name");
expect(result.drafts[0].missingFields).toContain("Raum");
expect(result.drafts[0].missingFields).toContain("Preis");
});
it("kennzeichnet Instanzen eines Dauerauftrags als Serie", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([
completeDraft({ parentCourseId: "template-1" }),
completeDraft({ id: "c2", parentCourseId: null }),
] as never);
const result = await getPublishableDrafts();
expect(result.drafts[0].isSeriesInstance).toBe(true);
expect(result.drafts[1].isSeriesInstance).toBe(false);
});
});
describe("publishDraftCourses", () => {
it("lehnt nicht angemeldete Nutzer ab", async () => {
mockGetUserData.mockResolvedValueOnce(null as never);
const result = await publishDraftCourses(["c1"]);
expect(result.success).toBe(false);
expect(transaction).not.toHaveBeenCalled();
});
it("lehnt eine leere Auswahl ab", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
const result = await publishDraftCourses([]);
expect(result.success).toBe(false);
expect(findMany).not.toHaveBeenCalled();
});
it("scoped die Abfrage auf eigene Entwürfe", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([] as never);
await publishDraftCourses(["c1", "fremd"]);
expect(findMany.mock.calls[0][0]?.where).toEqual({
id: { in: ["c1", "fremd"] },
createdBy: "mgr-1",
status: "draft",
isStandingOrder: false,
});
});
it("veröffentlicht vollständige Entwürfe", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
runTransaction(["c1"]);
const result = await publishDraftCourses(["c1"]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.published).toBe(1);
expect(result.publishedIds).toEqual(["c1"]);
expect(result.skipped).toEqual([]);
});
it("überspringt unvollständige Entwürfe statt sie zu veröffentlichen", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([
completeDraft(),
completeDraft({ id: "c2", name: "Zumba", room: "" }),
] as never);
runTransaction(["c1"]);
const result = await publishDraftCourses(["c1", "c2"]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.published).toBe(1);
expect(result.publishedIds).toEqual(["c1"]);
expect(result.skipped).toHaveLength(1);
expect(result.skipped[0].name).toBe("Zumba");
expect(result.skipped[0].reason).toContain("Raum");
});
it("zählt IDs, die die Abfrage nicht zurückgibt, als verschwunden", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
runTransaction(["c1"]);
const result = await publishDraftCourses(["c1", "geloescht", "fremd"]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.vanished).toBe(2);
});
it("schreibt gar nicht, wenn kein Kurs veröffentlichbar ist", async () => {
mockGetUserData.mockResolvedValueOnce(MANAGER as never);
findMany.mockResolvedValueOnce([completeDraft({ name: "", room: "" })] as never);
const result = await publishDraftCourses(["c1"]);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.published).toBe(0);
expect(transaction).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
});
it("erlaubt Employees mit edit-Berechtigung das Veröffentlichen im Namen des Managers", async () => {
mockGetUserData.mockResolvedValueOnce({
id: "emp-1",
role: "employee",
managerId: "mgr-1",
permissions: { courses: { edit: true } },
} as never);
findMany.mockResolvedValueOnce([completeDraft()] as never);
runTransaction(["c1"]);
const result = await publishDraftCourses(["c1"]);
expect(result.success).toBe(true);
expect(findMany.mock.calls[0][0]?.where).toMatchObject({ createdBy: "mgr-1" });
});
});