Kuma/src/modules/courses/ui/components/PublishDraftsDialog.tsx
2026-09-15 13:53:27 +02:00

469 lines
16 KiB
TypeScript

"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;
// Schlägt das Laden fehl, muss der alte Stand weg: publishDraftCourses
// bekommt den Zeitraum nicht mit und würde einen noch gültigen Entwurf aus
// dem VORHERIGEN Zeitraum anstandslos veröffentlichen, wenn er nach dem
// Fehler ausgewählt im Dialog stehen bleibt.
const clearDrafts = () => {
setDrafts([]);
setSelectedIds(new Set());
setPage(0);
};
(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 {
clearDrafts();
toast.error(result.error);
}
} catch {
if (!cancelled) {
clearDrafts();
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;