mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
469 lines
16 KiB
TypeScript
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;
|