mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
fixed couople of bugs
This commit is contained in:
parent
54b57632e8
commit
6da4e9f55d
15 changed files with 436 additions and 192 deletions
|
|
@ -2,19 +2,27 @@ import { Suspense } from "react";
|
|||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { getCancellationPolicy } from "@/src/modules/settings/actions/cancellation-settings-actions";
|
||||
import SettingsCancellationPageView from "@/src/modules/settings/ui/views/settings-cancellation-page-view";
|
||||
import type { CancellationPolicy } from "@/src/modules/settings/schemas/cancellation-policy-schema";
|
||||
import SettingsLoading from "../loading";
|
||||
|
||||
const DEFAULT_POLICY: CancellationPolicy = {
|
||||
enabled: true,
|
||||
mode: "tiered",
|
||||
fixedRefundPercent: 100,
|
||||
tiers: [],
|
||||
};
|
||||
|
||||
export default async function SettingsCancellationPage() {
|
||||
const manager = await requireManager();
|
||||
const businesses = manager.businesses.map((b) => ({ id: b.id, name: b.name }));
|
||||
|
||||
const firstBusinessId = businesses[0]?.id;
|
||||
const policyResult = firstBusinessId ? await getCancellationPolicy(firstBusinessId) : null;
|
||||
const initialTiers = policyResult?.success ? policyResult.tiers : [];
|
||||
const initialPolicy = policyResult?.success ? policyResult.policy : DEFAULT_POLICY;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<SettingsLoading />}>
|
||||
<SettingsCancellationPageView businesses={businesses} initialTiers={initialTiers} />
|
||||
<SettingsCancellationPageView businesses={businesses} initialPolicy={initialPolicy} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -257,10 +257,26 @@ type CapacityOutcome = {
|
|||
| "already_paid"
|
||||
| "already_refunded"
|
||||
| "already_failed"
|
||||
| "already_processed"
|
||||
| "course_not_found";
|
||||
bookingId?: string;
|
||||
};
|
||||
|
||||
// Status, die eine bereits abschließend verarbeitete Buchung markieren - bei
|
||||
// erneuter Zustellung DERSELBEN Checkout-Session (Stripe-Retry, manuelles
|
||||
// Redeliver im Dashboard) darf der Webhook den Datensatz auf KEINEN Fall per
|
||||
// upsert erneut auf "paid" setzen. Das ist explizit als Zulassungsliste
|
||||
// formuliert (statt einzelner if-Zweige), damit ein künftig neu eingeführter
|
||||
// paymentStatus-Wert automatisch mit abgedeckt ist, statt vergessen zu werden.
|
||||
const ALREADY_PROCESSED_STATUSES = [
|
||||
"paid",
|
||||
"refunded",
|
||||
"failed",
|
||||
"partially_refunded",
|
||||
"cancelled",
|
||||
"refunding",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Atomare Kapazitätsprüfung für authentifizierte User.
|
||||
*/
|
||||
|
|
@ -287,6 +303,13 @@ async function runCapacityTransactionUser(params: {
|
|||
return { result: "already_refunded" as const, bookingId: existing.id };
|
||||
if (existing.paymentStatus === "failed")
|
||||
return { result: "already_failed" as const, bookingId: existing.id };
|
||||
// Alle übrigen abschließenden Status (z.B. eine inzwischen erfolgte
|
||||
// Stornierung: "partially_refunded"/"cancelled"/"refunding") dürfen
|
||||
// NICHT erneut per upsert auf "paid" zurückgesetzt werden - das würde
|
||||
// eine bereits stornierte Buchung fälschlich wieder als aktiv/bezahlt
|
||||
// markieren, obwohl das Geld ganz oder teilweise bereits erstattet ist.
|
||||
if (ALREADY_PROCESSED_STATUSES.includes(existing.paymentStatus as never))
|
||||
return { result: "already_processed" as const, bookingId: existing.id };
|
||||
}
|
||||
|
||||
const course = await tx.course.findUnique({
|
||||
|
|
|
|||
20
src/lib/course-time.ts
Normal file
20
src/lib/course-time.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Kombiniert Course.date (Datum, i.d.R. Mitternacht UTC) mit Course.timeFrom
|
||||
* ("HH:mm") zu einem exakten Zeitpunkt. Course.date allein trägt keine
|
||||
* verlässliche Uhrzeit - ein reiner Datumsvergleich (courseDate < now) würde
|
||||
* einen Kurs bereits ab 00:00 Uhr des Kurstages als "vergangen" einstufen,
|
||||
* selbst wenn er erst abends stattfindet. Wird sowohl für die Stornierungs-
|
||||
* Cutoff-Berechnung als auch für "vergangen/bevorstehend"-Anzeigen genutzt.
|
||||
*
|
||||
* Bei einem ungültigen timeHHmm-Wert (z.B. leerer String, kein "HH:mm"-Format)
|
||||
* würde Number(...) NaN liefern; setHours(NaN, ...) ergäbe ein Invalid Date.
|
||||
* Deshalb wird ein nicht-endlicher Wert explizit auf 0 (Mitternacht) abgefangen.
|
||||
*/
|
||||
export function combineDateAndTime(date: Date, timeHHmm: string): Date {
|
||||
const [rawHours, rawMinutes] = (timeHHmm ?? "").split(":").map(Number);
|
||||
const hours = Number.isFinite(rawHours) ? rawHours : 0;
|
||||
const minutes = Number.isFinite(rawMinutes) ? rawMinutes : 0;
|
||||
const combined = new Date(date);
|
||||
combined.setHours(hours, minutes, 0, 0);
|
||||
return combined;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { prisma } from "@/src/lib/prisma";
|
|||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||
import { unstable_cache, revalidatePath, revalidateTag } from "next/cache";
|
||||
import { executeBookingCancellation } from "./cancel-booking-shared";
|
||||
import { combineDateAndTime } from "@/src/lib/course-time";
|
||||
|
||||
export type UserBooking = {
|
||||
bookingId: string;
|
||||
|
|
@ -122,7 +123,7 @@ export async function getUserBookings(): Promise<
|
|||
const now = new Date();
|
||||
|
||||
return bookingsRaw.map((b) => {
|
||||
const courseDate = new Date(b.course.date);
|
||||
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
|
||||
return {
|
||||
bookingId: b.id,
|
||||
courseId: b.course.id,
|
||||
|
|
@ -141,7 +142,7 @@ export async function getUserBookings(): Promise<
|
|||
trainers: b.course.trainers,
|
||||
trainersMap,
|
||||
paymentStatus: b.paymentStatus,
|
||||
isPast: courseDate < now,
|
||||
isPast: courseStart < now,
|
||||
} satisfies UserBooking;
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -107,6 +107,25 @@ export async function executeBookingCancellation(
|
|||
};
|
||||
}
|
||||
|
||||
// Erst prüfen, ob Stornierung für dieses Business überhaupt erlaubt ist,
|
||||
// BEVOR die Buchung auf "refunding" geclaimt wird - sonst würde eine
|
||||
// gesperrte Stornierung die Buchung trotzdem in den Zwischenstatus schieben.
|
||||
const eligibility = await calculateRefundAmountCents({
|
||||
businessId: booking.course.businessId,
|
||||
amountPaidCents: booking.amountPaid,
|
||||
courseStart,
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
if (!eligibility.cancellationAllowed) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Stornierungen sind für dieses Business derzeit nicht möglich.",
|
||||
};
|
||||
}
|
||||
|
||||
const { refundPercent, refundAmountCents } = eligibility;
|
||||
|
||||
// Race-Schutz: bevor Stripe kontaktiert wird, wird die Buchung atomar von
|
||||
// "paid" auf den Zwischenstatus "refunding" geclaimt. Zwei parallele Aufrufe
|
||||
// (Doppelklick, erneuter Server-Action-Aufruf, o.ä.) können beide den obigen
|
||||
|
|
@ -123,13 +142,6 @@ export async function executeBookingCancellation(
|
|||
return { success: false, error: "Stornierung läuft bereits oder wurde bereits verarbeitet." };
|
||||
}
|
||||
|
||||
const { refundPercent, refundAmountCents } = await calculateRefundAmountCents({
|
||||
businessId: booking.course.businessId,
|
||||
amountPaidCents: booking.amountPaid,
|
||||
courseStart,
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
// Stripe lehnt einen Refund mit amount: 0 ab ("must be greater than or equal
|
||||
// to 1") - bei 0% Rückerstattung wird die Buchung storniert, ohne Stripe
|
||||
// überhaupt zu kontaktieren.
|
||||
|
|
|
|||
|
|
@ -1,30 +1,25 @@
|
|||
import { prisma } from "@/src/lib/prisma";
|
||||
import {
|
||||
cancellationPolicySchema,
|
||||
type CancellationPolicy,
|
||||
type CancellationTier,
|
||||
} from "@/src/modules/settings/schemas/cancellation-policy-schema";
|
||||
|
||||
/**
|
||||
* Kombiniert Course.date (Datum) mit Course.timeFrom ("HH:mm") zu einem exakten
|
||||
* Zeitpunkt. Bisher wurde im Code nur Course.date verglichen und timeFrom ignoriert,
|
||||
* wodurch Stornierungs-Cutoffs am selben Tag ungenau waren.
|
||||
* Bei einem ungültigen timeHHmm-Wert (z.B. leerer String, kein "HH:mm"-Format)
|
||||
* würde Number(...) NaN liefern; setHours(NaN, ...) ergäbe ein Invalid Date, das
|
||||
* jeden nachfolgenden Vergleich in selectRefundPercent auf false kippen und so
|
||||
* fälschlich 0% Rückerstattung erzeugen würde. Deshalb wird ein nicht-endlicher
|
||||
* Wert explizit auf 0 (Mitternacht) abgefangen, statt NaN durchzureichen.
|
||||
*/
|
||||
export function combineDateAndTime(date: Date, timeHHmm: string): Date {
|
||||
const [rawHours, rawMinutes] = (timeHHmm ?? "").split(":").map(Number);
|
||||
const hours = Number.isFinite(rawHours) ? rawHours : 0;
|
||||
const minutes = Number.isFinite(rawMinutes) ? rawMinutes : 0;
|
||||
const combined = new Date(date);
|
||||
combined.setHours(hours, minutes, 0, 0);
|
||||
return combined;
|
||||
}
|
||||
// combineDateAndTime lebt in src/lib/course-time.ts, weil es nicht nur von der
|
||||
// Stornierungslogik gebraucht wird, sondern auch von "vergangen/bevorstehend"-
|
||||
// Anzeigen (Meine Kurse, Kunden-Detailansicht) - re-exportiert hier, damit
|
||||
// bestehende Importe aus diesem Modul weiter funktionieren.
|
||||
export { combineDateAndTime } from "@/src/lib/course-time";
|
||||
|
||||
const DEFAULT_POLICY: CancellationPolicy = {
|
||||
enabled: true,
|
||||
mode: "tiered",
|
||||
fixedRefundPercent: 100,
|
||||
tiers: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Wählt den zutreffenden Rückerstattungsprozentsatz.
|
||||
* Wählt den zutreffenden Rückerstattungsprozentsatz für den "tiered"-Modus.
|
||||
*
|
||||
* Eine Stufe {hoursBeforeStart: X, refundPercent: P} gilt für den Zeitraum
|
||||
* "sobald weniger als X Stunden bis Kursbeginn übrig sind, bis zur nächststrengeren
|
||||
|
|
@ -71,26 +66,41 @@ export function selectRefundPercent(
|
|||
return 0;
|
||||
}
|
||||
|
||||
async function loadPolicy(businessId: string | null): Promise<CancellationPolicy> {
|
||||
if (!businessId) return DEFAULT_POLICY;
|
||||
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { cancellationPolicy: true },
|
||||
});
|
||||
const parsed = cancellationPolicySchema.safeParse(business?.cancellationPolicy);
|
||||
// Bei fehlendem/ungültigem Json -> Fallback auf Default (Storno erlaubt, 100%).
|
||||
return parsed.success ? parsed.data : DEFAULT_POLICY;
|
||||
}
|
||||
|
||||
export type RefundEligibility = {
|
||||
cancellationAllowed: boolean;
|
||||
refundPercent: number;
|
||||
refundAmountCents: number;
|
||||
};
|
||||
|
||||
export async function calculateRefundAmountCents(params: {
|
||||
businessId: string | null;
|
||||
amountPaidCents: number;
|
||||
courseStart: Date;
|
||||
now: Date;
|
||||
}): Promise<{ refundPercent: number; refundAmountCents: number }> {
|
||||
let tiers: CancellationTier[] = [];
|
||||
}): Promise<RefundEligibility> {
|
||||
const policy = await loadPolicy(params.businessId);
|
||||
|
||||
if (params.businessId) {
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: params.businessId },
|
||||
select: { cancellationPolicy: true },
|
||||
});
|
||||
const parsed = cancellationPolicySchema.safeParse(business?.cancellationPolicy);
|
||||
if (parsed.success) tiers = parsed.data.tiers;
|
||||
// Bei fehlendem/ungültigem Json bleibt tiers leer -> Fallback auf 100%, kein Absturz.
|
||||
if (!policy.enabled) {
|
||||
return { cancellationAllowed: false, refundPercent: 0, refundAmountCents: 0 };
|
||||
}
|
||||
|
||||
const refundPercent = selectRefundPercent(tiers, params.courseStart, params.now);
|
||||
const refundPercent =
|
||||
policy.mode === "fixed"
|
||||
? policy.fixedRefundPercent
|
||||
: selectRefundPercent(policy.tiers, params.courseStart, params.now);
|
||||
const refundAmountCents = Math.round((params.amountPaidCents * refundPercent) / 100);
|
||||
|
||||
return { refundPercent, refundAmountCents };
|
||||
return { cancellationAllowed: true, refundPercent, refundAmountCents };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { getUserData, isUser, isManager } from "@/src/lib/auth/getUser";
|
|||
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
|
||||
|
||||
export type CancellationPreview = {
|
||||
cancellationAllowed: boolean;
|
||||
refundPercent: number;
|
||||
refundAmountCents: number;
|
||||
amountPaidCents: number;
|
||||
|
|
@ -54,15 +55,21 @@ export async function previewBookingCancellation(
|
|||
|
||||
const courseStart = combineDateAndTime(booking.course.date, booking.course.timeFrom);
|
||||
|
||||
const { refundPercent, refundAmountCents } = await calculateRefundAmountCents({
|
||||
businessId: booking.course.businessId,
|
||||
amountPaidCents: booking.amountPaid,
|
||||
courseStart,
|
||||
now: new Date(),
|
||||
});
|
||||
const { cancellationAllowed, refundPercent, refundAmountCents } =
|
||||
await calculateRefundAmountCents({
|
||||
businessId: booking.course.businessId,
|
||||
amountPaidCents: booking.amountPaid,
|
||||
courseStart,
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
preview: { refundPercent, refundAmountCents, amountPaidCents: booking.amountPaid },
|
||||
preview: {
|
||||
cancellationAllowed,
|
||||
refundPercent,
|
||||
refundAmountCents,
|
||||
amountPaidCents: booking.amountPaid,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
|
|
@ -50,59 +50,66 @@ interface CancelBookingConfirmDialogProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Bestätigungs-Dialog vor dem Stornieren einer Buchung, gemeinsam genutzt vom
|
||||
* Kunden-Self-Service-Pfad (UserBookingCard) und dem Manager-Pfad
|
||||
* (kunde-booking-card). Lädt vor der Bestätigung eine Vorschau des
|
||||
* Rückerstattungsbetrags und warnt sichtbar, wenn keine 100% erstattet werden.
|
||||
* Innerer Inhalt, der pro Öffnen frisch gemounted wird (key={bookingId} am
|
||||
* Aufrufer) - dadurch starten preview/loadingPreview bei jedem Öffnen bereits
|
||||
* mit korrekten Initialwerten und der Lade-Effect muss sie nicht mehr per
|
||||
* setState im Effect-Body zurücksetzen (verletzt sonst die react-hooks/
|
||||
* set-state-in-effect-Regel: kein setState direkt im Effect-Body ohne
|
||||
* externen Auslöser).
|
||||
*/
|
||||
const CancelBookingConfirmDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
function CancelBookingDialogBody({
|
||||
bookingId,
|
||||
courseName,
|
||||
variant,
|
||||
onConfirm,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CancelBookingConfirmDialogProps) => {
|
||||
}: {
|
||||
bookingId: string;
|
||||
courseName: string;
|
||||
variant: CancelBookingCopyVariant;
|
||||
onConfirm: () => Promise<{ success: true } | { success: false; error: string }>;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [preview, setPreview] = useState<CancellationPreview | null>(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [loadingPreview, setLoadingPreview] = useState(true);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const copy = VARIANT_COPY[variant];
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
onOpenChange(nextOpen);
|
||||
if (nextOpen) {
|
||||
loadPreview();
|
||||
} else {
|
||||
setPreview(null);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadPreview = async () => {
|
||||
setPreview(null);
|
||||
setLoadingPreview(true);
|
||||
try {
|
||||
const result = await previewBookingCancellation(bookingId);
|
||||
if (result.success) {
|
||||
setPreview(result.preview);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
onOpenChange(false);
|
||||
(async () => {
|
||||
try {
|
||||
const result = await previewBookingCancellation(bookingId);
|
||||
if (cancelled) return;
|
||||
if (result.success) {
|
||||
setPreview(result.preview);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
onClose();
|
||||
}
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
toast.error("Rückerstattung konnte nicht berechnet werden.");
|
||||
onClose();
|
||||
} finally {
|
||||
if (!cancelled) setLoadingPreview(false);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Rückerstattung konnte nicht berechnet werden.");
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setLoadingPreview(false);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookingId]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsPending(true);
|
||||
try {
|
||||
const result = await onConfirm();
|
||||
if (result.success) {
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
|
|
@ -115,40 +122,48 @@ const CancelBookingConfirmDialog = ({
|
|||
};
|
||||
|
||||
const isPartialRefund = preview !== null && preview.refundPercent < 100;
|
||||
const cancellationBlocked = preview !== null && !preview.cancellationAllowed;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Buchung stornieren?</DialogTitle>
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Buchung stornieren?</DialogTitle>
|
||||
{!cancellationBlocked && (
|
||||
<DialogDescription>
|
||||
Die Buchung für <strong>{courseName}</strong> {copy.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{loadingPreview ? (
|
||||
<p className="text-sm text-gray-500">Berechne Rückerstattung…</p>
|
||||
) : preview ? (
|
||||
isPartialRefund ? (
|
||||
<div className="flex gap-2 text-sm bg-orange-50 border border-orange-200 rounded-lg p-3 text-orange-800">
|
||||
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||
<p>
|
||||
{copy.partialRefund(formatPercent(preview.refundPercent))}{" "}
|
||||
<strong>{formatPrice(preview.refundAmountCents)}</strong> von{" "}
|
||||
{formatPrice(preview.amountPaidCents)}.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">
|
||||
{copy.fullRefund} ({formatPrice(preview.amountPaidCents)})
|
||||
{loadingPreview ? (
|
||||
<p className="text-sm text-gray-500">Berechne Rückerstattung…</p>
|
||||
) : cancellationBlocked ? (
|
||||
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-3 text-red-700">
|
||||
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||
<p>Stornierungen sind für diese Buchung derzeit nicht möglich.</p>
|
||||
</div>
|
||||
) : preview ? (
|
||||
isPartialRefund ? (
|
||||
<div className="flex gap-2 text-sm bg-orange-50 border border-orange-200 rounded-lg p-3 text-orange-800">
|
||||
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||
<p>
|
||||
{copy.partialRefund(formatPercent(preview.refundPercent))}{" "}
|
||||
<strong>{formatPrice(preview.refundAmountCents)}</strong> von{" "}
|
||||
{formatPrice(preview.amountPaidCents)}.
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">
|
||||
{copy.fullRefund} ({formatPrice(preview.amountPaidCents)})
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isPending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={isPending}>
|
||||
{cancellationBlocked ? "Schließen" : "Abbrechen"}
|
||||
</Button>
|
||||
{!cancellationBlocked && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
|
|
@ -156,7 +171,49 @@ const CancelBookingConfirmDialog = ({
|
|||
>
|
||||
{isPending ? "Wird storniert…" : "Ja, stornieren"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestätigungs-Dialog vor dem Stornieren einer Buchung, gemeinsam genutzt vom
|
||||
* Kunden-Self-Service-Pfad (UserBookingCard) und dem Manager-Pfad
|
||||
* (kunde-booking-card). Lädt vor der Bestätigung eine Vorschau des
|
||||
* Rückerstattungsbetrags und warnt sichtbar, wenn keine 100% erstattet werden
|
||||
* oder Stornierungen für das Business deaktiviert sind.
|
||||
*/
|
||||
const CancelBookingConfirmDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
bookingId,
|
||||
courseName,
|
||||
variant,
|
||||
onConfirm,
|
||||
onSuccess,
|
||||
}: CancelBookingConfirmDialogProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
{/* key={bookingId} sorgt für einen frischen Mount pro Öffnen - Radix'
|
||||
onOpenChange feuert nur bei intern (ESC, Overlay-Klick, DialogClose)
|
||||
ausgelösten Wechseln, nicht wenn "open" von außen auf true gesetzt
|
||||
wird, daher lädt CancelBookingDialogBody sich selbst beim Mount. */}
|
||||
{open && (
|
||||
<CancelBookingDialogBody
|
||||
key={bookingId}
|
||||
bookingId={bookingId}
|
||||
courseName={courseName}
|
||||
variant={variant}
|
||||
onConfirm={onConfirm}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { prisma } from "@/src/lib/prisma";
|
||||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { combineDateAndTime } from "@/src/lib/course-time";
|
||||
|
||||
export type KundeBooking = {
|
||||
bookingId: string;
|
||||
|
|
@ -94,8 +95,9 @@ export async function getKundeDetail(
|
|||
maxParticipants: true,
|
||||
coverImage: true,
|
||||
description: true,
|
||||
// Anzahl aller Buchungen für diesen Kurs
|
||||
_count: { select: { bookings: true } },
|
||||
// Nur bezahlte Buchungen zählen als belegte Plätze - pending/failed/
|
||||
// refunded/cancelled/partially_refunded blockieren keinen Platz mehr.
|
||||
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -151,7 +153,7 @@ export async function getKundeDetail(
|
|||
const now = new Date();
|
||||
|
||||
const bookings: KundeBooking[] = userWithBookings.courseBookings.map((b) => {
|
||||
const courseDate = new Date(b.course.date);
|
||||
const courseStart = combineDateAndTime(b.course.date, b.course.timeFrom);
|
||||
return {
|
||||
bookingId: b.id,
|
||||
courseId: b.course.id,
|
||||
|
|
@ -170,7 +172,7 @@ export async function getKundeDetail(
|
|||
trainers: b.course.trainers,
|
||||
trainersMap,
|
||||
paymentStatus: b.paymentStatus,
|
||||
isPast: courseDate < now,
|
||||
isPast: courseStart < now,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,16 @@ export type KundeRow = {
|
|||
name: string;
|
||||
email: string;
|
||||
telefon: string;
|
||||
status: "Bezahlt" | "Storniert" | "Ausstehend";
|
||||
status: "Bezahlt" | "Storniert" | "Teilweise erstattet" | "Ausstehend" | "Fehlgeschlagen";
|
||||
isGuest: boolean;
|
||||
};
|
||||
|
||||
function mapPaymentStatus(paymentStatus: string): KundeRow["status"] {
|
||||
if (paymentStatus === "paid") return "Bezahlt";
|
||||
if (paymentStatus === "refunded") return "Storniert";
|
||||
if (paymentStatus === "refunded" || paymentStatus === "cancelled") return "Storniert";
|
||||
if (paymentStatus === "partially_refunded") return "Teilweise erstattet";
|
||||
if (paymentStatus === "failed") return "Fehlgeschlagen";
|
||||
// "pending" und der kurzlebige Zwischenstatus "refunding" fallen hier rein.
|
||||
return "Ausstehend";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import type { Kunde } from "../views/kunden-view";
|
|||
const STATUS_STYLES: Record<Kunde["status"], string> = {
|
||||
Bezahlt: "bg-green-100 text-green-700",
|
||||
Storniert: "bg-red-100 text-red-600",
|
||||
"Teilweise erstattet": "bg-orange-100 text-orange-700",
|
||||
Ausstehend: "bg-yellow-100 text-yellow-700",
|
||||
Fehlgeschlagen: "bg-red-100 text-red-600",
|
||||
};
|
||||
|
||||
interface KundenTableProps {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,19 @@ import { requireManager } from "@/src/lib/auth/getUser";
|
|||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
cancellationPolicySchema,
|
||||
type CancellationTier,
|
||||
type CancellationPolicy,
|
||||
} from "../schemas/cancellation-policy-schema";
|
||||
|
||||
const DEFAULT_POLICY: CancellationPolicy = {
|
||||
enabled: true,
|
||||
mode: "tiered",
|
||||
fixedRefundPercent: 100,
|
||||
tiers: [],
|
||||
};
|
||||
|
||||
export async function getCancellationPolicy(
|
||||
businessId: string
|
||||
): Promise<{ success: true; tiers: CancellationTier[] } | { success: false; error: string }> {
|
||||
): Promise<{ success: true; policy: CancellationPolicy } | { success: false; error: string }> {
|
||||
// requireManager() löst bei fehlender Session/Rolle einen next/navigation-Redirect
|
||||
// aus, der als spezieller Error implementiert ist - darf nicht im try/catch landen.
|
||||
const manager = await requireManager();
|
||||
|
|
@ -25,7 +32,7 @@ export async function getCancellationPolicy(
|
|||
}
|
||||
|
||||
const parsed = cancellationPolicySchema.safeParse(business.cancellationPolicy);
|
||||
return { success: true, tiers: parsed.success ? parsed.data.tiers : [] };
|
||||
return { success: true, policy: parsed.success ? parsed.data : DEFAULT_POLICY };
|
||||
} catch (error) {
|
||||
console.error("Error fetching cancellation policy:", error);
|
||||
return { success: false, error: "Stornierungsregeln konnten nicht geladen werden." };
|
||||
|
|
@ -34,7 +41,7 @@ export async function getCancellationPolicy(
|
|||
|
||||
export async function saveCancellationPolicy(
|
||||
businessId: string,
|
||||
tiers: CancellationTier[]
|
||||
policy: CancellationPolicy
|
||||
): Promise<{ success: true } | { success: false; error: string }> {
|
||||
const manager = await requireManager();
|
||||
|
||||
|
|
@ -47,17 +54,21 @@ export async function saveCancellationPolicy(
|
|||
return { success: false, error: "Keine Berechtigung für dieses Business." };
|
||||
}
|
||||
|
||||
const parsed = cancellationPolicySchema.safeParse({ tiers });
|
||||
const parsed = cancellationPolicySchema.safeParse(policy);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Ungültige Stornierungsregeln." };
|
||||
}
|
||||
|
||||
const hoursSeen = new Set<number>();
|
||||
for (const tier of parsed.data.tiers) {
|
||||
if (hoursSeen.has(tier.hoursBeforeStart)) {
|
||||
return { success: false, error: "Zwei Stufen können nicht denselben Zeitpunkt haben." };
|
||||
// Der Zeitschwellen-Check ist nur im "tiered"-Modus sinnvoll - im "fixed"-
|
||||
// Modus gibt es keine Stufen, die kollidieren könnten.
|
||||
if (parsed.data.mode === "tiered") {
|
||||
const hoursSeen = new Set<number>();
|
||||
for (const tier of parsed.data.tiers) {
|
||||
if (hoursSeen.has(tier.hoursBeforeStart)) {
|
||||
return { success: false, error: "Zwei Stufen können nicht denselben Zeitpunkt haben." };
|
||||
}
|
||||
hoursSeen.add(tier.hoursBeforeStart);
|
||||
}
|
||||
hoursSeen.add(tier.hoursBeforeStart);
|
||||
}
|
||||
|
||||
const sortedTiers = [...parsed.data.tiers].sort(
|
||||
|
|
@ -66,7 +77,7 @@ export async function saveCancellationPolicy(
|
|||
|
||||
await prisma.business.update({
|
||||
where: { id: businessId },
|
||||
data: { cancellationPolicy: { tiers: sortedTiers } },
|
||||
data: { cancellationPolicy: { ...parsed.data, tiers: sortedTiers } },
|
||||
});
|
||||
|
||||
revalidatePath("/settings/cancellation");
|
||||
|
|
|
|||
|
|
@ -9,7 +9,16 @@ export const cancellationTierSchema = z.object({
|
|||
refundPercent: z.number().int().min(0).max(100),
|
||||
});
|
||||
|
||||
// "tiered": zeitgestaffelte Prozentsätze über die tiers-Liste (Standard).
|
||||
// "fixed": immer derselbe fixedRefundPercent, unabhängig vom Stornierungszeitpunkt.
|
||||
// enabled=false: Stornierung ist für dieses Business komplett deaktiviert - das
|
||||
// ist bewusst von einer 0%-tiered-Regel unterschieden, weil dort eine
|
||||
// Stornierung technisch weiterhin möglich bliebe (nur ohne Rückerstattung),
|
||||
// während enabled=false den Stornieren-Button gar nicht erst anzeigt.
|
||||
export const cancellationPolicySchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
mode: z.enum(["tiered", "fixed"]).default("tiered"),
|
||||
fixedRefundPercent: z.number().int().min(0).max(100).default(100),
|
||||
tiers: z.array(cancellationTierSchema).max(20),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,15 @@ import {
|
|||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical, Plus, Trash2, Info } from "lucide-react";
|
||||
import { GripVertical, Plus, Trash2, Info, Ban } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Input } from "@/src/components/ui/input";
|
||||
import { Switch } from "@/src/components/ui/switch";
|
||||
import CancellationPolicyTimeline from "./CancellationPolicyTimeline";
|
||||
import type { CancellationTier } from "../../schemas/cancellation-policy-schema";
|
||||
import type {
|
||||
CancellationPolicy,
|
||||
CancellationTier,
|
||||
} from "../../schemas/cancellation-policy-schema";
|
||||
|
||||
type TierRow = CancellationTier & { id: string };
|
||||
|
||||
|
|
@ -88,21 +92,24 @@ const SortableTierRow = ({ tier, onChange, onDelete }: SortableTierRowProps) =>
|
|||
};
|
||||
|
||||
interface CancellationPolicyEditorProps {
|
||||
initialTiers: CancellationTier[];
|
||||
onSave: (tiers: CancellationTier[]) => Promise<boolean>;
|
||||
initialPolicy: CancellationPolicy;
|
||||
onSave: (policy: CancellationPolicy) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const CancellationPolicyEditor = ({
|
||||
initialTiers,
|
||||
initialPolicy,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: CancellationPolicyEditorProps) => {
|
||||
const idPrefix = useId();
|
||||
const [enabled, setEnabled] = useState(initialPolicy.enabled);
|
||||
const [mode, setMode] = useState(initialPolicy.mode);
|
||||
const [fixedRefundPercent, setFixedRefundPercent] = useState(initialPolicy.fixedRefundPercent);
|
||||
const [rows, setRows] = useState<TierRow[]>(
|
||||
initialTiers.map((tier, idx) => ({ ...tier, id: `${idPrefix}-${idx}` }))
|
||||
initialPolicy.tiers.map((tier, idx) => ({ ...tier, id: `${idPrefix}-${idx}` }))
|
||||
);
|
||||
const [nextId, setNextId] = useState(initialTiers.length);
|
||||
const [nextId, setNextId] = useState(initialPolicy.tiers.length);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||
|
||||
|
|
@ -138,6 +145,11 @@ const CancellationPolicyEditor = ({
|
|||
setNextId((n) => n + 1);
|
||||
};
|
||||
|
||||
const handleFixedPercentChange = (value: number) => {
|
||||
const safeValue = Number.isFinite(value) ? Math.min(100, Math.max(0, Math.round(value))) : 0;
|
||||
setFixedRefundPercent(safeValue);
|
||||
};
|
||||
|
||||
// Zeilen ohne jede Eingabe (frisch per "Stufe hinzufügen" angelegt, noch
|
||||
// nicht bearbeitet) werden nicht mitgespeichert - sonst würde eine
|
||||
// unbearbeitete 0h→0%-Zeile fälschlich als echte Stufe persistiert, obwohl
|
||||
|
|
@ -149,8 +161,9 @@ const CancellationPolicyEditor = ({
|
|||
.filter((t) => t.hoursBeforeStart > 0 || t.refundPercent > 0);
|
||||
|
||||
const handleSave = async () => {
|
||||
const policy: CancellationPolicy = { enabled, mode, fixedRefundPercent, tiers: validTiers };
|
||||
try {
|
||||
const success = await onSave(validTiers);
|
||||
const success = await onSave(policy);
|
||||
if (!success) return;
|
||||
// Nach erfolgreichem Speichern serverseitig sortierte Reihenfolge übernehmen.
|
||||
const sorted = [...validTiers].sort((a, b) => b.hoursBeforeStart - a.hoursBeforeStart);
|
||||
|
|
@ -163,58 +176,125 @@ const CancellationPolicyEditor = ({
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 text-sm text-gray-600 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
|
||||
<Info size={16} className="shrink-0 mt-0.5 text-gray-400" />
|
||||
<p>
|
||||
Jede Stufe gilt <strong>ab</strong> ihrer Zeitschwelle bis zur nächststrengeren Stufe.
|
||||
Storniert ein Kunde z.B. bei den Stufen 24h→50% und 12h→25% genau 15 Stunden vor
|
||||
Kursbeginn, gilt die 24h-Stufe (50%), weil er die 24h-Schwelle noch nicht unterschritten
|
||||
hat. Die strengste konfigurierte Stufe gilt bis Kursbeginn weiter — für{" "}
|
||||
<strong>0%</strong> ab einem bestimmten Zeitpunkt muss also explizit eine eigene Stufe mit
|
||||
0% angelegt werden.
|
||||
</p>
|
||||
{/* Storno global erlauben/sperren */}
|
||||
<div className="flex items-center justify-between gap-3 border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4">
|
||||
<div className="flex gap-2">
|
||||
<Ban size={16} className="shrink-0 mt-0.5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Stornierung erlauben</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Ist dies deaktiviert, können Kunden und Manager gebuchte Kurse gar nicht mehr
|
||||
stornieren - der Stornieren-Button wird nicht mehr angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
|
||||
Ohne konfigurierte Stufen erhalten Kunden bei Stornierung immer 100% Rückerstattung.
|
||||
</p>
|
||||
) : (
|
||||
{enabled && (
|
||||
<>
|
||||
{validTiers.length > 0 && <CancellationPolicyTimeline tiers={validTiers} />}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2">
|
||||
{rows.map((tier) => (
|
||||
<SortableTierRow
|
||||
key={tier.id}
|
||||
tier={tier}
|
||||
onChange={handleChange}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
{/* Modus: zeitgestaffelt vs. fester Prozentsatz */}
|
||||
<div className="flex gap-2 border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === "tiered" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setMode("tiered")}
|
||||
>
|
||||
Zeitbasierte Staffelung
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={mode === "fixed" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setMode("fixed")}
|
||||
>
|
||||
Fester Prozentsatz
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mode === "fixed" ? (
|
||||
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-4 space-y-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
Kunden erhalten bei jeder Stornierung immer denselben Prozentsatz zurück, unabhängig
|
||||
davon wie kurzfristig vor Kursbeginn storniert wird.
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 max-w-xs">
|
||||
<label className="text-xs text-gray-500">Rückerstattung in %</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={fixedRefundPercent}
|
||||
onChange={(e) => handleFixedPercentChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<p className="text-xs text-gray-400">
|
||||
Die Reihenfolge wird beim Speichern automatisch nach Zeitpunkt sortiert.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2 text-sm text-gray-600 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
|
||||
<Info size={16} className="shrink-0 mt-0.5 text-gray-400" />
|
||||
<p>
|
||||
Jede Stufe gilt <strong>ab</strong> ihrer Zeitschwelle bis zur nächststrengeren
|
||||
Stufe. Storniert ein Kunde z.B. bei den Stufen 24h→50% und 12h→25% genau 15
|
||||
Stunden vor Kursbeginn, gilt die 24h-Stufe (50%), weil er die 24h-Schwelle noch
|
||||
nicht unterschritten hat. Die strengste konfigurierte Stufe gilt bis Kursbeginn
|
||||
weiter — für <strong>0%</strong> ab einem bestimmten Zeitpunkt muss also explizit
|
||||
eine eigene Stufe mit 0% angelegt werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 border border-white/60 bg-white/40 backdrop-blur-xl rounded-xl p-4">
|
||||
Ohne konfigurierte Stufen erhalten Kunden bei Stornierung immer 100%
|
||||
Rückerstattung.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{validTiers.length > 0 && <CancellationPolicyTimeline tiers={validTiers} />}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={rows.map((r) => r.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{rows.map((tier) => (
|
||||
<SortableTierRow
|
||||
key={tier.id}
|
||||
tier={tier}
|
||||
onChange={handleChange}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<p className="text-xs text-gray-400">
|
||||
Die Reihenfolge wird beim Speichern automatisch nach Zeitpunkt sortiert.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleAdd}>
|
||||
<Plus size={14} className="mr-1.5" />
|
||||
Stufe hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleAdd}>
|
||||
<Plus size={14} className="mr-1.5" />
|
||||
Stufe hinzufügen
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,19 +15,19 @@ import {
|
|||
getCancellationPolicy,
|
||||
saveCancellationPolicy,
|
||||
} from "../../actions/cancellation-settings-actions";
|
||||
import type { CancellationTier } from "../../schemas/cancellation-policy-schema";
|
||||
import type { CancellationPolicy } from "../../schemas/cancellation-policy-schema";
|
||||
|
||||
interface SettingsCancellationPageViewProps {
|
||||
businesses: { id: string; name: string }[];
|
||||
initialTiers: CancellationTier[];
|
||||
initialPolicy: CancellationPolicy;
|
||||
}
|
||||
|
||||
const SettingsCancellationPageView = ({
|
||||
businesses,
|
||||
initialTiers,
|
||||
initialPolicy,
|
||||
}: SettingsCancellationPageViewProps) => {
|
||||
const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? "");
|
||||
const [tiers, setTiers] = useState(initialTiers);
|
||||
const [policy, setPolicy] = useState(initialPolicy);
|
||||
// Getrennt von "keine Stufen konfiguriert" - eine fehlgeschlagene Anfrage
|
||||
// darf niemals als leere Policy dargestellt werden, sonst würde ein
|
||||
// "Speichern" versehentlich die tatsächlich gespeicherten Stufen mit einer
|
||||
|
|
@ -43,7 +43,7 @@ const SettingsCancellationPageView = ({
|
|||
startLoadingPolicy(async () => {
|
||||
const result = await getCancellationPolicy(businessId);
|
||||
if (result.success) {
|
||||
setTiers(result.tiers);
|
||||
setPolicy(result.policy);
|
||||
} else {
|
||||
// Auswahl zurücksetzen, damit die UI nicht unter dem neuen
|
||||
// Business-Namen die (Stufen des vorherigen Business gehörenden) Werte
|
||||
|
|
@ -55,11 +55,11 @@ const SettingsCancellationPageView = ({
|
|||
});
|
||||
};
|
||||
|
||||
const handleSave = (newTiers: CancellationTier[]): Promise<boolean> => {
|
||||
const handleSave = (newPolicy: CancellationPolicy): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
startSaving(async () => {
|
||||
try {
|
||||
const result = await saveCancellationPolicy(selectedBusinessId, newTiers);
|
||||
const result = await saveCancellationPolicy(selectedBusinessId, newPolicy);
|
||||
if (result.success) {
|
||||
toast.success("Stornierungsregeln gespeichert.");
|
||||
resolve(true);
|
||||
|
|
@ -82,8 +82,7 @@ const SettingsCancellationPageView = ({
|
|||
<CalendarClock /> Stornierung
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Lege fest, wie viel Kunden bei einer Stornierung zurückerstattet bekommen, abhängig davon
|
||||
wie kurzfristig vor Kursbeginn storniert wird.
|
||||
Lege fest, ob und wie viel Kunden bei einer Stornierung zurückerstattet bekommen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -117,7 +116,7 @@ const SettingsCancellationPageView = ({
|
|||
<div className={loadingPolicy ? "opacity-60 transition-opacity" : "transition-opacity"}>
|
||||
<CancellationPolicyEditor
|
||||
key={selectedBusinessId}
|
||||
initialTiers={tiers}
|
||||
initialPolicy={policy}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue