mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
see comments
This commit is contained in:
parent
cffca6b2dd
commit
94d65c4517
5 changed files with 76 additions and 24 deletions
|
|
@ -24,7 +24,12 @@ export async function previewCourseCancellation(
|
|||
{ success: true; preview: CourseCancellationPreview } | { success: false; error: string }
|
||||
> {
|
||||
const userData = await getUserData();
|
||||
if (!userData) return { success: false, error: "Nicht angemeldet." };
|
||||
if (
|
||||
!userData ||
|
||||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.courses.delete))
|
||||
) {
|
||||
return { success: false, error: "Keine Berechtigung zum Stornieren von Kursen" };
|
||||
}
|
||||
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
|
|
@ -64,14 +69,23 @@ export type CourseCancellationResult = {
|
|||
* verschwindet, iteriert danach über alle bezahlten Buchungen und erstattet
|
||||
* jede zu 100% über die geteilte executeBookingCancellation-Logik
|
||||
* (forceFullRefund: die Kunden trifft hier keine Schuld, anders als bei einer
|
||||
* Selbst-Stornierung gilt daher nicht die zeitbasierte Policy). Schlägt eine
|
||||
* einzelne Stripe-Erstattung fehl, läuft die Schleife für die übrigen
|
||||
* Buchungen trotzdem weiter - der Manager sieht am Ende, wie viele
|
||||
* fehlgeschlagen sind und kann sie manuell nachbearbeiten.
|
||||
* Selbst-Stornierung gilt daher nicht die zeitbasierte Policy).
|
||||
*
|
||||
* Idempotent/retrybar: die Buchungs-Query filtert live auf paymentStatus:
|
||||
* "paid", nicht auf einen vor der Schleife eingefrorenen Snapshot-Zustand des
|
||||
* Kurses. Schlägt eine einzelne Stripe-Erstattung fehl, bleibt diese Buchung
|
||||
* "paid" (executeBookingCancellation rollt den "refunding"-Zwischenstatus bei
|
||||
* einem Stripe-Fehler zurück) und wird bei einem erneuten Aufruf dieser
|
||||
* Funktion (z.B. erneuter Klick auf "Kurs absagen" durch den Manager) wieder
|
||||
* gefunden und erneut versucht - deshalb gibt cancelCourse unten KEINEN Fehler
|
||||
* zurück, wenn der Kurs bereits "cancelled" ist, sondern lässt einen Retry zu.
|
||||
*/
|
||||
async function executeCourseCancellation(courseId: string): Promise<CourseCancellationResult> {
|
||||
await prisma.course.update({
|
||||
where: { id: courseId },
|
||||
// updateMany + Bedingung status != "cancelled" statt update: bei einem
|
||||
// Retry (Kurs ist schon "cancelled") soll cancelledAt NICHT überschrieben
|
||||
// werden - der ursprüngliche Absage-Zeitpunkt muss stehen bleiben.
|
||||
await prisma.course.updateMany({
|
||||
where: { id: courseId, status: { not: "cancelled" } },
|
||||
data: { status: "cancelled", cancelledAt: new Date() },
|
||||
});
|
||||
|
||||
|
|
@ -147,10 +161,11 @@ export async function cancelCourse(
|
|||
if (course.createdBy !== managerId) {
|
||||
return { success: false, error: "Sie können nur Ihre eigenen Kurse stornieren" };
|
||||
}
|
||||
if (course.status === "cancelled") {
|
||||
return { success: false, error: "Dieser Kurs wurde bereits storniert" };
|
||||
}
|
||||
|
||||
// Kein Block bei status === "cancelled": ein erneuter Aufruf muss möglich
|
||||
// bleiben, damit Buchungen, deren Erstattung beim letzten Versuch
|
||||
// fehlgeschlagen ist, erneut versucht werden können (siehe Docblock an
|
||||
// executeCourseCancellation).
|
||||
const result = await executeCourseCancellation(courseId);
|
||||
|
||||
if (course.businessId) {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ interface UseCancelCourseOptions {
|
|||
|
||||
export function useCancelCourse({ onSuccess }: UseCancelCourseOptions = {}) {
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [courseToCancel, setCourseToCancel] = useState<{ id: string; name: string } | null>(null);
|
||||
const [courseToCancel, setCourseToCancel] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
isRetry: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const handleCancelClick = (id: string, name: string) => {
|
||||
setCourseToCancel({ id, name });
|
||||
const handleCancelClick = (id: string, name: string, isRetry = false) => {
|
||||
setCourseToCancel({ id, name, isRetry });
|
||||
setCancelDialogOpen(true);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ interface CancelCourseConfirmDialogProps {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
/** true, wenn der Kurs bereits "cancelled" ist und dies ein Retry für zuvor
|
||||
* fehlgeschlagene Erstattungen ist, statt der Erst-Absage. */
|
||||
isRetry?: boolean;
|
||||
onConfirm: () => Promise<
|
||||
| { success: true; cancelled: number; failed: number; errors: string[] }
|
||||
| { success: false; error: string }
|
||||
|
|
@ -38,12 +41,14 @@ interface CancelCourseConfirmDialogProps {
|
|||
function CancelCourseDialogBody({
|
||||
courseId,
|
||||
courseName,
|
||||
isRetry,
|
||||
onConfirm,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
isRetry?: boolean;
|
||||
onConfirm: CancelCourseConfirmDialogProps["onConfirm"];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
|
|
@ -87,17 +92,25 @@ function CancelCourseDialogBody({
|
|||
if (result.success) {
|
||||
if (result.failed > 0) {
|
||||
toast.warning(
|
||||
`Kurs abgesagt. ${result.cancelled} Erstattung(en) erfolgreich, ${result.failed} fehlgeschlagen — bitte manuell prüfen.`
|
||||
`${result.cancelled} Erstattung(en) erfolgreich, ${result.failed} weiterhin fehlgeschlagen — bitte manuell prüfen.`
|
||||
);
|
||||
} else {
|
||||
toast.success("Kurs abgesagt und alle Kunden erstattet.");
|
||||
toast.success(
|
||||
isRetry
|
||||
? "Verbleibende Erstattungen erfolgreich."
|
||||
: "Kurs abgesagt und alle Kunden erstattet."
|
||||
);
|
||||
}
|
||||
onSuccess();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Kurs konnte nicht abgesagt werden.");
|
||||
toast.error(
|
||||
isRetry
|
||||
? "Erstattungen konnten nicht erneut versucht werden."
|
||||
: "Kurs konnte nicht abgesagt werden."
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
|
|
@ -106,10 +119,19 @@ function CancelCourseDialogBody({
|
|||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kurs absagen?</DialogTitle>
|
||||
<DialogTitle>{isRetry ? "Erstattungen erneut versuchen?" : "Kurs absagen?"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{courseName}</strong> wird als abgesagt markiert und verschwindet sofort aus der
|
||||
öffentlichen Buchungsseite. Dieser Vorgang kann nicht rückgängig gemacht werden.
|
||||
{isRetry ? (
|
||||
<>
|
||||
<strong>{courseName}</strong> ist bereits als abgesagt markiert. Für zuvor
|
||||
fehlgeschlagene Erstattungen wird ein neuer Versuch gestartet.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>{courseName}</strong> wird als abgesagt markiert und verschwindet sofort aus
|
||||
der öffentlichen Buchungsseite. Dieser Vorgang kann nicht rückgängig gemacht werden.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -127,7 +149,8 @@ function CancelCourseDialogBody({
|
|||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Es gibt keine bezahlten Buchungen für diesen Kurs — es wird niemand benachrichtigt.
|
||||
Es gibt keine offenen bezahlten Buchungen für diesen Kurs — es wird niemand
|
||||
benachrichtigt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -142,7 +165,7 @@ function CancelCourseDialogBody({
|
|||
onClick={handleConfirm}
|
||||
disabled={isPending || loadingPreview}
|
||||
>
|
||||
{isPending ? "Wird abgesagt…" : "Ja, Kurs absagen"}
|
||||
{isPending ? "Wird verarbeitet…" : isRetry ? "Ja, erneut versuchen" : "Ja, Kurs absagen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
|
@ -154,6 +177,7 @@ const CancelCourseConfirmDialog = ({
|
|||
onOpenChange,
|
||||
courseId,
|
||||
courseName,
|
||||
isRetry,
|
||||
onConfirm,
|
||||
onSuccess,
|
||||
}: CancelCourseConfirmDialogProps) => {
|
||||
|
|
@ -165,6 +189,7 @@ const CancelCourseConfirmDialog = ({
|
|||
key={courseId}
|
||||
courseId={courseId}
|
||||
courseName={courseName}
|
||||
isRetry={isRetry}
|
||||
onConfirm={onConfirm}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onSuccess={() => {
|
||||
|
|
|
|||
|
|
@ -234,11 +234,11 @@ const CourseListItem: React.FC<CourseListItemProps> = ({
|
|||
<Link size={20} className="text-blue-600" />
|
||||
</button>
|
||||
)}
|
||||
{status === "published" && !isPast && onCancel && (
|
||||
{(status === "published" || status === "cancelled") && !isPast && onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 rounded-md hover:bg-red-500/10 transition"
|
||||
title="Kurs absagen"
|
||||
title={status === "cancelled" ? "Erstattung erneut versuchen" : "Kurs absagen"}
|
||||
>
|
||||
<Ban size={20} className="text-red-600" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -485,7 +485,14 @@ const CourseListView = ({
|
|||
}
|
||||
onEdit={canEdit ? () => router.push(`/courses/edit/${course.id}`) : undefined}
|
||||
onCancel={
|
||||
canDelete ? () => handleCancelClick(course.id, course.name) : undefined
|
||||
canDelete
|
||||
? () =>
|
||||
handleCancelClick(
|
||||
course.id,
|
||||
course.name,
|
||||
course.status === "cancelled"
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -532,6 +539,7 @@ const CourseListView = ({
|
|||
}}
|
||||
courseId={courseToCancel.id}
|
||||
courseName={courseToCancel.name}
|
||||
isRetry={courseToCancel.isRetry}
|
||||
onConfirm={handleCancelConfirm}
|
||||
onSuccess={() => setCourseToCancel(null)}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue