Merge pull request #171 from Since-3/feature/added-floor-plan

added floor plan to rooms
This commit is contained in:
Rajpreet0 2026-10-02 01:00:17 +02:00 • committed by GitHub
commit 55f0163af5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 3004 additions and 117 deletions

View file

@ -0,0 +1,34 @@
-- AlterTable
ALTER TABLE "Course" ADD COLUMN "spotBooking" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "CourseBooking" ADD COLUMN "spotId" TEXT,
ADD COLUMN "spotLabel" TEXT;
-- AlterTable
ALTER TABLE "Room" ADD COLUMN "floorPlan" JSONB;
-- CreateTable
CREATE TABLE "CourseSpotHold" (
"id" TEXT NOT NULL,
"courseId" TEXT NOT NULL,
"spotId" TEXT NOT NULL,
"holderKey" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CourseSpotHold_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CourseSpotHold_courseId_idx" ON "CourseSpotHold"("courseId");
-- CreateIndex
CREATE UNIQUE INDEX "CourseSpotHold_courseId_spotId_key" ON "CourseSpotHold"("courseId", "spotId");
-- CreateIndex
CREATE INDEX "CourseBooking_spotId_idx" ON "CourseBooking"("spotId");
-- AddForeignKey
ALTER TABLE "CourseSpotHold" ADD CONSTRAINT "CourseSpotHold_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CourseSpotHold" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'checkout',
ADD COLUMN "stripeSessionId" TEXT;

View file

@ -172,9 +172,13 @@ model Course {
createdBy String // Manager ID who created the course createdBy String // Manager ID who created the course
businessId String? businessId String?
coverImage String? coverImage String?
// Kunden wählen beim Buchen ein Gerät aus dem Grundriss des Raums. Wirkt nur,
// solange der Raum buchbare Geräte hat - sonst wird ganz normal ohne Platz gebucht.
spotBooking Boolean @default(false)
business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull) business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull)
bookings CourseBooking[] bookings CourseBooking[]
waitlistEntries CourseWaitlistEntry[] waitlistEntries CourseWaitlistEntry[]
spotHolds CourseSpotHold[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -190,6 +194,10 @@ model Room {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
createdBy String // Manager ID who created the room createdBy String // Manager ID who created the room
// Grundriss mit Geräten: { width, depth, items: [{ id, type, x, y, rotation, label }] }.
// Format und Gerätekatalog siehe src/modules/rooms/lib/floor-plan.ts. null = kein Grundriss.
// Die item-IDs sind stabil und stehen als CourseBooking.spotId an Buchungen.
floorPlan Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -226,6 +234,8 @@ model CourseBooking {
cancellationReason String? cancellationReason String?
discountCodeId String? // Gesetzt, sobald bei der Buchung ein gültiger Rabattcode eingelöst wurde discountCodeId String? // Gesetzt, sobald bei der Buchung ein gültiger Rabattcode eingelöst wurde
discountAmount Int? // Tatsächlich gewährter Rabatt in Cents (Snapshot, unabhängig von späteren Code-Änderungen) discountAmount Int? // Tatsächlich gewährter Rabatt in Cents (Snapshot, unabhängig von späteren Code-Änderungen)
spotId String? // Gerät aus dem Raum-Grundriss (Room.floorPlan.items[].id), nur bei Kursen mit Platzwahl. Belegt ist ein Platz, solange die Buchung "paid" ist.
spotLabel String? // Anzeigename des Platzes als Momentaufnahme, z.B. "Reformer 3" - bleibt lesbar, auch wenn der Grundriss sich später ändert
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull) guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull)
@ -238,6 +248,31 @@ model CourseBooking {
@@index([userId]) @@index([userId])
@@index([guestLeadId]) @@index([guestLeadId])
@@index([discountCodeId]) @@index([discountCodeId])
@@index([spotId])
}
// Hält einen Platz frei, während der Kunde im Stripe-Checkout bezahlt - analog
// zur DiscountRedemption-Reservierung. Ohne sie könnten zwei Kunden gleichzeitig
// mit demselben Reformer zur Kasse gehen. Entsteht beim Checkout-Start, wird im
// Webhook nach der Zahlung gelöscht (dann belegt die Buchung den Platz) oder bei
// abgelaufener Session freigegeben. Abgelaufene Holds (expiresAt < jetzt) zählen
// nicht und werden beim nächsten Reservieren aufgeräumt.
// holderKey = "user:<id>" oder "guest:<email>": pro Person und Kurs läuft höchstens
// ein offener Checkout - wer zurück geht und neu wählt, beendet den alten (siehe
// Checkout-Route). Holds abgeschlossener Checkouts bleiben dabei unangetastet.
model CourseSpotHold {
id String @id @default(uuid())
courseId String
spotId String
holderKey String
expiresAt DateTime
stripeSessionId String? // Checkout-Session, für die der Platz gehalten wird. null, solange sie angelegt wird.
status String @default("checkout") // "checkout" (Session offen) | "awaiting_payment" (Checkout abgeschlossen, SEPA-Zahlung unterwegs)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([courseId, spotId])
@@index([courseId])
} }
// Bewusst nur für registrierte User (kein guestEmail-Feld): ein // Bewusst nur für registrierte User (kein guestEmail-Feld): ein

View file

@ -11,6 +11,16 @@ import {
releaseDiscountReservation, releaseDiscountReservation,
calculateDiscountedPriceCents, calculateDiscountedPriceCents,
} from "@/src/modules/settings/lib/discount-validation"; } from "@/src/modules/settings/lib/discount-validation";
import {
attachSessionToHold,
getCourseSpots,
getOpenCheckoutHolds,
holdSpot,
releaseSpotHold,
spotHolderKey,
SPOT_HOLD_MINUTES,
} from "@/src/modules/courses/lib/course-spots";
import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
/** /**
* Erstellt die Checkout-Session im Branding des Studios. Lehnt Stripe das * Erstellt die Checkout-Session im Branding des Studios. Lehnt Stripe das
@ -40,6 +50,40 @@ async function createBrandedCheckoutSession(
} }
} }
/**
* Pro Person und Kurs läuft höchstens ein offener Checkout mit Platz. Startet
* jemand neu (zurück aus dem Checkout, zweiter Tab), wird die alte Session bei
* Stripe beendet und ihr Platz freigegeben - so kann sie auch nicht später
* noch bezahlt werden.
*
* Freigegeben wird nur, was Stripe nachweislich als offen meldet. Ist die alte
* Session schon abgeschlossen (bezahlt, Webhook noch unterwegs), behält sie
* ihren Platz. Im Zweifel (Stripe nicht erreichbar) bleibt der Hold stehen und
* läuft von selbst ab.
*/
async function endOpenCheckouts(courseId: string, holderKey: string, stripeAccountId: string) {
const options = { stripeAccount: stripeAccountId };
for (const hold of await getOpenCheckoutHolds(prisma, courseId, holderKey)) {
// Ohne Session-ID wurde die Session nie fertig angelegt - nichts zu beenden.
if (!hold.stripeSessionId) {
await releaseSpotHold(prisma, hold.id);
continue;
}
try {
const old = await stripe.checkout.sessions.retrieve(hold.stripeSessionId, {}, options);
if (old.status === "complete") continue;
if (old.status === "open") await stripe.checkout.sessions.expire(old.id, {}, options);
await releaseSpotHold(prisma, hold.id);
} catch (error) {
console.warn(
`Alte Checkout-Session ${hold.stripeSessionId} konnte nicht beendet werden, ` +
`Platz-Hold ${hold.id} bleibt bis zum Ablauf bestehen:`,
error
);
}
}
}
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute. // Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
// Best-effort only — in multi-instance/serverless deployments each instance has // Best-effort only — in multi-instance/serverless deployments each instance has
// its own Map, so a determined attacker can bypass it. The main purpose is to // its own Map, so a determined attacker can bypass it. The main purpose is to
@ -79,6 +123,8 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
* *
* Auth-Flow → Body: { courseId: string } * Auth-Flow → Body: { courseId: string }
* Guest-Flow → Body: { courseId: string; guestEmail: string; guestName?: string } * Guest-Flow → Body: { courseId: string; guestEmail: string; guestName?: string }
* Kurse mit Platzwahl brauchen zusätzlich { spotId } - der Platz wird für die
* Dauer der Session freigehalten (siehe course-spots.ts).
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@ -102,8 +148,9 @@ export async function POST(request: NextRequest) {
guestEmail?: string; guestEmail?: string;
guestName?: string; guestName?: string;
discountCode?: string; discountCode?: string;
spotId?: string;
}; };
const { courseId, guestEmail, guestName, discountCode } = body; const { courseId, guestEmail, guestName, discountCode, spotId } = body;
if (!courseId) { if (!courseId) {
return NextResponse.json({ error: "courseId fehlt" }, { status: 400 }); return NextResponse.json({ error: "courseId fehlt" }, { status: 400 });
@ -280,51 +327,119 @@ export async function POST(request: NextRequest) {
reservationToken = reservation.reservationToken; reservationToken = reservation.reservationToken;
} }
// Fixe Servicegebühr, die der Kunde ZUSÄTZLICH zum Kurspreis zahlt und die const holderKey = spotHolderKey({
// als eigene Checkout-Position sichtbar ist. Der Gesamtbetrag der Charge userId: isGuest ? null : userData!.id,
// ist damit priceCents + platformFeeCents; application_fee_amount führt guestEmail: isGuest ? guestEmail : null,
// exakt die Gebühr an Kumasy ab. Rabattcodes mindern nur den Kurspreis, });
// nie die Gebühr. let spotHoldId: string | null = null;
const platformFeeCents = getPlatformFeeCents(); let spotLabel: string | null = null;
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email; // Ab hier ist ggf. ein Rabattcode reserviert und gleich ein Platz gehalten.
// Jeder Fehler bis zur fertigen Session muss beides wieder freigeben, sonst
// Alles, was der Webhook später braucht, um die Buchung anzulegen. Liegt // blieben sie blockiert, ohne dass je eine Buchung zustande kommt - eine
// an Session UND PaymentIntent, damit es auch im Stripe-Dashboard an der // nie angelegte Session meldet auch kein checkout.session.expired.
// Zahlung sichtbar ist. const releaseReservations = async () => {
const metadata = { if (reservationToken) {
courseId: course.id, await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
userId: isGuest ? "" : userData!.id, console.error(
businessId: course.business.id, `Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagenem ` +
isGuest: isGuest ? "true" : "false", `Checkout-Start nicht freigegeben werden:`,
guestEmail: isGuest ? customerEmail : "", releaseError
guestName: isGuest ? (guestName?.trim() ?? "") : "", );
discountCodeId: appliedDiscountCodeId ?? "", });
discountAmountCents: String(discountAmountCents), }
discountReservationToken: reservationToken ?? "", if (spotHoldId) {
// Sagt dem Webhook, dass die Gebühr obendrauf kam. Sessions aus der Zeit await releaseSpotHold(prisma, spotHoldId).catch((releaseError) => {
// der prozentualen Gebühr haben diesen Schlüssel nicht - eine solche console.error(
// Session, die erst nach einem Deploy bezahlt wird, darf nicht als `Platz-Hold ${spotHoldId} konnte nach fehlgeschlagenem Checkout-Start nicht ` +
// "obendrauf" verbucht werden. `freigegeben werden:`,
platformFeeModel: "on_top", releaseError
}; );
});
// Der Kunde bucht beim Studio - der Checkout trägt deshalb dessen Farben }
// und Logo, wie die öffentliche Seite davor. Ohne eigenes Branding greifen
// die Kumasy-Farben.
const studioBranding = toEmailBranding(course.business);
const brandingSettings: Stripe.Checkout.SessionCreateParams.BrandingSettings = {
display_name: course.business.name,
background_color: studioBranding.primaryColor,
button_color: studioBranding.accentColor,
border_style: "rounded",
...(studioBranding.logoUrl && isAllowedLogoUrl(studioBranding.logoUrl)
? { logo: { type: "url", url: studioBranding.logoUrl } }
: {}),
}; };
let session; let session;
try { try {
// Platzwahl: erst nach dem Rabattcode, damit ein abgelehnter Code keinen
// Platz blockiert.
const spots = await getCourseSpots(prisma, course);
if (spots) {
const spot = spots.find((s) => s.id === spotId);
if (!spot) {
await releaseReservations();
return NextResponse.json(
{
error: spotId
? "Diesen Platz gibt es nicht mehr. Bitte wählen Sie einen anderen."
: "Bitte wählen Sie einen Platz aus.",
spotUnavailable: true,
},
{ status: 400 }
);
}
await endOpenCheckouts(course.id, holderKey, course.business.stripeAccountId);
const hold = await holdSpot(prisma, {
courseId: course.id,
spotId: spot.id,
holderKey,
// Etwas länger als die Session, damit ein Kunde, der in der letzten
// Minute bezahlt, seinen Platz bis zum Webhook behält.
expiresAt: new Date(Date.now() + (SPOT_HOLD_MINUTES + 5) * 60_000),
});
if (!hold.held) {
await releaseReservations();
return NextResponse.json({ error: hold.reason, spotUnavailable: true }, { status: 409 });
}
spotHoldId = hold.holdId;
spotLabel = spotDisplayName(spot);
}
// Fixe Servicegebühr, die der Kunde ZUSÄTZLICH zum Kurspreis zahlt und die
// als eigene Checkout-Position sichtbar ist. Der Gesamtbetrag der Charge
// ist damit priceCents + platformFeeCents; application_fee_amount führt
// exakt die Gebühr an Kumasy ab. Rabattcodes mindern nur den Kurspreis,
// nie die Gebühr.
const platformFeeCents = getPlatformFeeCents();
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
// Alles, was der Webhook später braucht, um die Buchung anzulegen. Liegt
// an Session UND PaymentIntent, damit es auch im Stripe-Dashboard an der
// Zahlung sichtbar ist.
const metadata = {
courseId: course.id,
userId: isGuest ? "" : userData!.id,
businessId: course.business.id,
isGuest: isGuest ? "true" : "false",
guestEmail: isGuest ? customerEmail : "",
guestName: isGuest ? (guestName?.trim() ?? "") : "",
discountCodeId: appliedDiscountCodeId ?? "",
discountAmountCents: String(discountAmountCents),
discountReservationToken: reservationToken ?? "",
// Sagt dem Webhook, dass die Gebühr obendrauf kam. Sessions aus der Zeit
// der prozentualen Gebühr haben diesen Schlüssel nicht - eine solche
// Session, die erst nach einem Deploy bezahlt wird, darf nicht als
// "obendrauf" verbucht werden.
platformFeeModel: "on_top",
spotId: spotHoldId ? spotId! : "",
spotHoldId: spotHoldId ?? "",
};
// Der Kunde bucht beim Studio - der Checkout trägt deshalb dessen Farben
// und Logo, wie die öffentliche Seite davor. Ohne eigenes Branding greifen
// die Kumasy-Farben.
const studioBranding = toEmailBranding(course.business);
const brandingSettings: Stripe.Checkout.SessionCreateParams.BrandingSettings = {
display_name: course.business.name,
background_color: studioBranding.primaryColor,
button_color: studioBranding.accentColor,
border_style: "rounded",
...(studioBranding.logoUrl && isAllowedLogoUrl(studioBranding.logoUrl)
? { logo: { type: "url", url: studioBranding.logoUrl } }
: {}),
};
session = await createBrandedCheckoutSession( session = await createBrandedCheckoutSession(
{ {
mode: "payment", mode: "payment",
@ -337,7 +452,7 @@ export async function POST(request: NextRequest) {
currency: "eur", currency: "eur",
product_data: { product_data: {
name: course.name, name: course.name,
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}`, description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}${spotLabel ? ` · Platz: ${spotLabel}` : ""}`,
}, },
unit_amount: priceCents, unit_amount: priceCents,
}, },
@ -367,27 +482,30 @@ export async function POST(request: NextRequest) {
}, },
metadata, metadata,
customer_email: customerEmail, customer_email: customerEmail,
// Mit Platzwahl verfällt die Session, wenn der Platz-Hold endet -
// sonst könnte jemand nach Stunden einen längst freigegebenen Platz
// bezahlen. Ohne Platzwahl bleibt Stripes Standard (24 Stunden).
...(spotHoldId
? { expires_at: Math.floor(Date.now() / 1000) + SPOT_HOLD_MINUTES * 60 }
: {}),
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`, success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`, cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
}, },
brandingSettings, brandingSettings,
course.business.stripeAccountId course.business.stripeAccountId
); );
} catch (stripeError) { } catch (error) {
// Reservierung wurde bereits vor der Session-Erstellung angelegt (siehe await releaseReservations();
// oben) - schlägt die Session-Erstellung selbst fehl, muss der belegte throw error;
// Slot wieder freigegeben werden, sonst bliebe er dauerhaft blockiert, }
// ohne dass je eine Buchung zustande kommt.
if (reservationToken) { // Damit ein späterer Checkout derselben Person diese Session beenden kann
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => { // (siehe endOpenCheckouts). Scheitert das, bleibt der Platz trotzdem bis
console.error( // zum Ablauf gehalten - kein Grund, die fertige Session zu verwerfen.
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagener ` + if (spotHoldId && session.id) {
`Stripe-Session nicht freigegeben werden:`, await attachSessionToHold(prisma, spotHoldId, session.id).catch((err) =>
releaseError console.error(`Session konnte nicht an Platz-Hold ${spotHoldId} gehängt werden:`, err)
); );
});
}
throw stripeError;
} }
return NextResponse.json({ return NextResponse.json({

View file

@ -19,6 +19,14 @@ import {
confirmDiscountReservation, confirmDiscountReservation,
releaseDiscountReservation, releaseDiscountReservation,
} from "@/src/modules/settings/lib/discount-validation"; } from "@/src/modules/settings/lib/discount-validation";
import {
assignSpot,
extendSpotHold,
getCourseSpots,
releaseSpotHold,
DELAYED_PAYMENT_HOLD_DAYS,
type AssignedSpot,
} from "@/src/modules/courses/lib/course-spots";
/** /**
* Sucht einen registrierten User anhand der E-Mail, case-insensitive. * Sucht einen registrierten User anhand der E-Mail, case-insensitive.
@ -52,7 +60,7 @@ async function findUserByEmailInsensitive(email: string) {
* - checkout.session.async_payment_succeeded → die verzögerte Zahlung ist * - checkout.session.async_payment_succeeded → die verzögerte Zahlung ist
* angekommen, Buchung anlegen wie oben. * angekommen, Buchung anlegen wie oben.
* - checkout.session.expired / async_payment_failed → nie bezahlt: Rabattcode- * - checkout.session.expired / async_payment_failed → nie bezahlt: Rabattcode-
* Reservierung freigeben, Pending-Buchung aufräumen. * Reservierung und Platz-Hold freigeben, Pending-Buchung aufräumen.
* - charge.updated → Stripe-Gebühr nachtragen, sobald Stripe sie kennt. * - charge.updated → Stripe-Gebühr nachtragen, sobald Stripe sie kennt.
* - account.updated → Stripe Connect Account-Status synchronisieren. * - account.updated → Stripe Connect Account-Status synchronisieren.
*/ */
@ -189,15 +197,29 @@ async function handleCheckoutCompleted(
return; return;
} }
// Platzwahl (siehe course-spots.ts): Wunschplatz und der Hold, der ihn
// seit dem Checkout-Start freihält. Leer bei Kursen ohne Platzwahl.
const spotRequest = {
requestedSpotId: session.metadata?.spotId || null,
holdId: session.metadata?.spotHoldId || null,
};
if (session.payment_status !== "paid") { if (session.payment_status !== "paid") {
// Verzögerte Zahlungsart wie SEPA-Lastschrift: Der Kunde hat den Checkout // Verzögerte Zahlungsart wie SEPA-Lastschrift: Der Kunde hat den Checkout
// abgeschlossen, das Geld ist aber noch unterwegs. Gebucht wird erst bei // abgeschlossen, das Geld ist aber noch unterwegs. Gebucht wird erst bei
// checkout.session.async_payment_succeeded. Die Rabattcode-Reservierung // checkout.session.async_payment_succeeded. Die Rabattcode-Reservierung
// bleibt bis dahin bestehen. // bleibt bis dahin bestehen, der Platz-Hold wird bis dahin verlängert.
console.info( console.info(
`Session ${session.id} ist noch nicht bezahlt (${session.payment_status}) - ` + `Session ${session.id} ist noch nicht bezahlt (${session.payment_status}) - ` +
`warte auf checkout.session.async_payment_succeeded.` `warte auf checkout.session.async_payment_succeeded.`
); );
if (spotRequest.holdId) {
await extendSpotHold(
prisma,
spotRequest.holdId,
new Date(Date.now() + DELAYED_PAYMENT_HOLD_DAYS * 24 * 60 * 60_000)
);
}
return; return;
} }
@ -279,6 +301,7 @@ async function handleCheckoutCompleted(
userId: registeredUser.id, userId: registeredUser.id,
session, session,
payment, payment,
spotRequest,
discountCodeId, discountCodeId,
discountAmountCents, discountAmountCents,
discountReservationToken, discountReservationToken,
@ -290,6 +313,7 @@ async function handleCheckoutCompleted(
guestName: guestName || undefined, guestName: guestName || undefined,
session, session,
payment, payment,
spotRequest,
}); });
} else { } else {
const userId = session.metadata?.userId; const userId = session.metadata?.userId;
@ -328,6 +352,7 @@ async function handleCheckoutCompleted(
userId: registeredUser.id, userId: registeredUser.id,
session, session,
payment, payment,
spotRequest,
discountCodeId, discountCodeId,
discountAmountCents, discountAmountCents,
discountReservationToken, discountReservationToken,
@ -339,6 +364,7 @@ async function handleCheckoutCompleted(
guestName: session.metadata?.guestName || undefined, guestName: session.metadata?.guestName || undefined,
session, session,
payment, payment,
spotRequest,
}); });
} else { } else {
console.error( console.error(
@ -369,6 +395,7 @@ async function handleCheckoutCompleted(
userId, userId,
session, session,
payment, payment,
spotRequest,
discountCodeId, discountCodeId,
discountAmountCents, discountAmountCents,
discountReservationToken, discountReservationToken,
@ -403,6 +430,15 @@ async function handleCheckoutCompleted(
if (!outcome) throw new Error("runCapacityTransaction lieferte kein Ergebnis"); if (!outcome) throw new Error("runCapacityTransaction lieferte kein Ergebnis");
// Ab hier hält die Buchung den Platz (oder es gibt keine) - der Hold aus dem
// Checkout-Start hat seinen Zweck erfüllt. Ein Fehler beim Aufräumen ist
// harmlos: der Hold läuft ohnehin ab.
if (spotRequest.holdId) {
await releaseSpotHold(prisma, spotRequest.holdId).catch((err) =>
console.error(`Platz-Hold ${spotRequest.holdId} konnte nicht freigegeben werden:`, err)
);
}
const needsRefund = outcome.result === "capacity_exceeded" || outcome.result === "already_failed"; const needsRefund = outcome.result === "capacity_exceeded" || outcome.result === "already_failed";
let refundSucceeded = false; let refundSucceeded = false;
@ -470,7 +506,10 @@ async function handleCheckoutCompleted(
const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null; const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null;
if (recipientEmail) { if (recipientEmail) {
if (outcome.result === "booked") { if (outcome.result === "booked") {
await withTimeout(sendBookingConfirmation(recipientEmail, courseId, amountPaidCents), 2000); await withTimeout(
sendBookingConfirmation(recipientEmail, courseId, amountPaidCents, outcome.spot ?? null),
2000
);
} else if (needsRefund && refundSucceeded) { } else if (needsRefund && refundSucceeded) {
await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000); await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000);
} else if (outcome.result === "duplicate_payment" && duplicateRefundSucceeded) { } else if (outcome.result === "duplicate_payment" && duplicateRefundSucceeded) {
@ -530,8 +569,17 @@ type CapacityOutcome = {
| "course_not_found" | "course_not_found"
| "duplicate_payment"; | "duplicate_payment";
bookingId?: string; bookingId?: string;
/** Zugewiesener Platz bei Kursen mit Platzwahl (nur bei "booked"). */
spot?: AssignedSpot | null;
}; };
type SpotRequest = { requestedSpotId: string | null; holdId: string | null };
/** Spalten für den Platz einer Buchung - explizit null, damit ein Upsert alte Werte überschreibt. */
function spotColumns(spot: AssignedSpot | null) {
return { spotId: spot?.spotId ?? null, spotLabel: spot?.spotLabel ?? null };
}
// Status, die eine bereits abschließend verarbeitete Buchung markieren - bei // Status, die eine bereits abschließend verarbeitete Buchung markieren - bei
// erneuter Zustellung DERSELBEN Checkout-Session (Stripe-Retry, manuelles // erneuter Zustellung DERSELBEN Checkout-Session (Stripe-Retry, manuelles
// Redeliver im Dashboard) darf der Webhook den Datensatz auf KEINEN Fall per // Redeliver im Dashboard) darf der Webhook den Datensatz auf KEINEN Fall per
@ -562,6 +610,7 @@ async function runCapacityTransactionUser(params: {
userId: string; userId: string;
session: Stripe.Checkout.Session; session: Stripe.Checkout.Session;
payment: SessionPayment; payment: SessionPayment;
spotRequest?: SpotRequest;
discountCodeId?: string | null; discountCodeId?: string | null;
discountAmountCents?: number; discountAmountCents?: number;
discountReservationToken?: string | null; discountReservationToken?: string | null;
@ -572,6 +621,7 @@ async function runCapacityTransactionUser(params: {
userId, userId,
session, session,
payment, payment,
spotRequest = { requestedSpotId: null, holdId: null },
discountCodeId = null, discountCodeId = null,
discountAmountCents = 0, discountAmountCents = 0,
discountReservationToken = null, discountReservationToken = null,
@ -637,7 +687,7 @@ async function runCapacityTransactionUser(params: {
const course = await tx.course.findUnique({ const course = await tx.course.findUnique({
where: { id: courseId }, where: { id: courseId },
select: { maxParticipants: true }, select: { maxParticipants: true, spotBooking: true, room: true },
}); });
if (!course) return { result: "course_not_found" as const }; if (!course) return { result: "course_not_found" as const };
@ -646,12 +696,21 @@ async function runCapacityTransactionUser(params: {
where: { courseId, paymentStatus: "paid" }, where: { courseId, paymentStatus: "paid" },
}); });
if (paidCount >= course.maxParticipants) { // Bei Platzwahl ist der Kurs auch dann voll, wenn kein Platz mehr frei
// ist - etwa weil der Grundriss nach dem Checkout-Start verkleinert wurde.
const spots = await getCourseSpots(tx, course);
const spot =
spots && paidCount < course.maxParticipants
? await assignSpot(tx, { courseId, spots, ...spotRequest })
: null;
if (paidCount >= course.maxParticipants || (spots && !spot)) {
const booking = await tx.courseBooking.upsert({ const booking = await tx.courseBooking.upsert({
where: { courseId_userId: { courseId, userId } }, where: { courseId_userId: { courseId, userId } },
update: { update: {
paymentStatus: "failed", paymentStatus: "failed",
...paymentColumns(session.id, payment), ...paymentColumns(session.id, payment),
...spotColumns(null),
}, },
create: { create: {
courseId, courseId,
@ -668,6 +727,7 @@ async function runCapacityTransactionUser(params: {
update: { update: {
paymentStatus: "paid", paymentStatus: "paid",
...paymentColumns(session.id, payment), ...paymentColumns(session.id, payment),
...spotColumns(spot),
discountCodeId, discountCodeId,
discountAmount: discountCodeId ? discountAmountCents : null, discountAmount: discountCodeId ? discountAmountCents : null,
}, },
@ -676,6 +736,7 @@ async function runCapacityTransactionUser(params: {
userId, userId,
paymentStatus: "paid", paymentStatus: "paid",
...paymentColumns(session.id, payment), ...paymentColumns(session.id, payment),
...spotColumns(spot),
discountCodeId, discountCodeId,
discountAmount: discountCodeId ? discountAmountCents : null, discountAmount: discountCodeId ? discountAmountCents : null,
}, },
@ -697,7 +758,7 @@ async function runCapacityTransactionUser(params: {
}); });
} }
return { result: "booked" as const, bookingId: booking.id }; return { result: "booked" as const, bookingId: booking.id, spot };
}, },
{ isolationLevel: "Serializable" } { isolationLevel: "Serializable" }
); );
@ -714,8 +775,16 @@ async function runCapacityTransactionGuest(params: {
guestName?: string; guestName?: string;
session: Stripe.Checkout.Session; session: Stripe.Checkout.Session;
payment: SessionPayment; payment: SessionPayment;
spotRequest?: SpotRequest;
}): Promise<CapacityOutcome> { }): Promise<CapacityOutcome> {
const { courseId, guestEmail, guestName, session, payment } = params; const {
courseId,
guestEmail,
guestName,
session,
payment,
spotRequest = { requestedSpotId: null, holdId: null },
} = params;
return prisma.$transaction( return prisma.$transaction(
async (tx) => { async (tx) => {
@ -742,7 +811,7 @@ async function runCapacityTransactionGuest(params: {
const course = await tx.course.findUnique({ const course = await tx.course.findUnique({
where: { id: courseId }, where: { id: courseId },
select: { maxParticipants: true }, select: { maxParticipants: true, spotBooking: true, room: true },
}); });
if (!course) return { result: "course_not_found" as const }; if (!course) return { result: "course_not_found" as const };
@ -751,10 +820,16 @@ async function runCapacityTransactionGuest(params: {
where: { courseId, paymentStatus: "paid" }, where: { courseId, paymentStatus: "paid" },
}); });
const spots = await getCourseSpots(tx, course);
const spot =
spots && paidCount < course.maxParticipants
? await assignSpot(tx, { courseId, spots, ...spotRequest })
: null;
// GuestLead erst nach Kapazitätsprüfung anlegen — verhindert Phantom-Einträge // GuestLead erst nach Kapazitätsprüfung anlegen — verhindert Phantom-Einträge
// bei Überbuchung. upsert by email: ein Gast bekommt genau einen Lead-Eintrag, // bei Überbuchung. upsert by email: ein Gast bekommt genau einen Lead-Eintrag,
// auch bei mehreren Buchungen (@@unique auf email im Schema). // auch bei mehreren Buchungen (@@unique auf email im Schema).
if (paidCount >= course.maxParticipants) { if (paidCount >= course.maxParticipants || (spots && !spot)) {
const guestLead = await tx.guestLead.upsert({ const guestLead = await tx.guestLead.upsert({
where: { email: guestEmail }, where: { email: guestEmail },
update: {}, update: {},
@ -782,9 +857,10 @@ async function runCapacityTransactionGuest(params: {
guestLeadId: guestLead.id, guestLeadId: guestLead.id,
paymentStatus: "paid", paymentStatus: "paid",
...paymentColumns(session.id, payment), ...paymentColumns(session.id, payment),
...spotColumns(spot),
}, },
}); });
return { result: "booked" as const, bookingId: booking.id }; return { result: "booked" as const, bookingId: booking.id, spot };
}, },
{ isolationLevel: "Serializable" } { isolationLevel: "Serializable" }
); );
@ -803,7 +879,8 @@ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null
async function sendBookingConfirmation( async function sendBookingConfirmation(
recipientEmail: string, recipientEmail: string,
courseId: string, courseId: string,
amountPaidCents: number amountPaidCents: number,
spot: AssignedSpot | null
) { ) {
try { try {
const course = await prisma.course.findUnique({ const course = await prisma.course.findUnique({
@ -828,6 +905,8 @@ async function sendBookingConfirmation(
businessName: course.business.name, businessName: course.business.name,
businessAddress: course.business.address, businessAddress: course.business.address,
amountPaidCents, amountPaidCents,
spotLabel: spot?.spotLabel ?? null,
spotChanged: spot?.changed ?? false,
myCoursesUrl: `${baseUrl}/courses/myCourses`, myCoursesUrl: `${baseUrl}/courses/myCourses`,
coverImageUrl: course.coverImage, coverImageUrl: course.coverImage,
companyName: process.env.COMPANY_NAME, companyName: process.env.COMPANY_NAME,
@ -966,6 +1045,14 @@ async function handleCheckoutUnpaid(session: Stripe.Checkout.Session) {
const courseId = session.metadata?.courseId; const courseId = session.metadata?.courseId;
const isGuest = session.metadata?.isGuest === "true"; const isGuest = session.metadata?.isGuest === "true";
const discountReservationToken = session.metadata?.discountReservationToken || null; const discountReservationToken = session.metadata?.discountReservationToken || null;
const spotHoldId = session.metadata?.spotHoldId || null;
// Nie bezahlt - der Platz wird wieder frei.
if (spotHoldId) {
await releaseSpotHold(prisma, spotHoldId).catch((error) =>
console.error(`Platz-Hold ${spotHoldId} konnte nicht freigegeben werden:`, error)
);
}
// Gibt einen beim Checkout-Start reservierten Rabattcode-Slot wieder frei // Gibt einen beim Checkout-Start reservierten Rabattcode-Slot wieder frei
// (siehe reserveDiscountCode), da diese Session nie bezahlt wurde. Läuft // (siehe reserveDiscountCode), da diese Session nie bezahlt wurde. Läuft

View file

@ -4,7 +4,7 @@ import { Button } from "@/src/components/ui/button";
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions"; import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast"; import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
import { CheckCircle2, Calendar, Clock, MapPin } from "lucide-react"; import { CheckCircle2, Calendar, Clock, MapPin, LayoutGrid } from "lucide-react";
interface SuccessPageProps { interface SuccessPageProps {
params: Promise<{ courseId: string }>; params: Promise<{ courseId: string }>;
@ -87,6 +87,14 @@ export default async function CourseBookingSuccessPage({ params, searchParams }:
<span>{course.business.address}</span> <span>{course.business.address}</span>
</div> </div>
)} )}
{result.success && result.spotLabel && (
<div className="flex items-center gap-2.5">
<LayoutGrid size={15} className="text-gray-400 shrink-0" />
<span>
Ihr Platz: <b>{result.spotLabel}</b>
</span>
</div>
)}
</div> </div>
<div className="border-t border-gray-200 mt-4 pt-3"> <div className="border-t border-gray-200 mt-4 pt-3">

View file

@ -0,0 +1,57 @@
"use client";
// TEMPORÄR - nur für die visuelle Prüfung, wird wieder gelöscht.
import { useState } from "react";
import FloorPlanEditor from "@/src/modules/rooms/ui/components/floor-plan/FloorPlanEditor";
import { SpotPicker } from "@/src/modules/rooms/ui/components/floor-plan/SpotPicker";
import type { FloorPlan, FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
const r = (n: number, x: number, y: number): FloorPlanItem => ({
id: `r${n}`,
type: "reformer",
x,
y,
rotation: 0,
label: String(n),
});
const initial: FloorPlan = {
width: 8,
depth: 6,
items: [
{ id: "mirror", type: "mirror", x: 4, y: 0.06, rotation: 0, label: "" },
{ id: "trainer", type: "trainer", x: 4, y: 0.7, rotation: 0, label: "" },
{ id: "door", type: "door", x: 7.2, y: 5.9, rotation: 180, label: "" },
r(1, 1.0, 2.5),
r(2, 2.0, 2.5),
r(3, 3.0, 2.5),
r(4, 4.0, 2.5),
r(5, 5.0, 2.5),
r(6, 6.0, 2.5),
{ id: "c1", type: "chair", x: 7.4, y: 1.5, rotation: 90, label: "1" },
{ id: "m1", type: "mat", x: 1.5, y: 5.0, rotation: 90, label: "1" },
{ id: "b1", type: "barrel", x: 4.5, y: 5.0, rotation: 90, label: "1" },
{ id: "t1", type: "tower", x: 7.4, y: 3.8, rotation: 0, label: "1" },
],
};
export default function Page() {
const [plan, setPlan] = useState(initial);
const [sel, setSel] = useState<string | null>("r3");
return (
<div className="p-6 flex flex-col gap-10 max-w-5xl mx-auto">
<section className="border border-white/60 bg-white/60 rounded-2xl p-5">
<h2 className="font-semibold mb-4">Editor</h2>
<FloorPlanEditor value={plan} onChange={setPlan} />
</section>
<section className="border rounded-2xl p-5 bg-white max-w-3xl">
<h2 className="font-semibold mb-4">Platzwahl</h2>
<SpotPicker
floorPlan={plan}
takenSpotIds={["r1", "r5", "c1"]}
selectedSpotId={sel}
onSelect={setSel}
accentColor="#0f766e"
/>
</section>
</div>
);
}

View file

@ -18,6 +18,10 @@ interface BookingConfirmationEmailParams {
businessName: string; businessName: string;
businessAddress?: string; businessAddress?: string;
amountPaidCents: number; amountPaidCents: number;
/** Gebuchter Platz bei Kursen mit Platzwahl, z.B. "Reformer 3". */
spotLabel?: string | null;
/** true = der Wunschplatz war bei Zahlungseingang vergeben, spotLabel ist ein anderer. */
spotChanged?: boolean;
myCoursesUrl: string; myCoursesUrl: string;
coverImageUrl?: string | null; coverImageUrl?: string | null;
companyName?: string; companyName?: string;
@ -49,6 +53,8 @@ export function generateBookingConfirmationEmail({
businessName, businessName,
businessAddress, businessAddress,
amountPaidCents, amountPaidCents,
spotLabel,
spotChanged = false,
myCoursesUrl, myCoursesUrl,
coverImageUrl, coverImageUrl,
companyName = "Ihr Unternehmen", companyName = "Ihr Unternehmen",
@ -60,6 +66,10 @@ export function generateBookingConfirmationEmail({
const safeBusinessAddress = businessAddress ? escapeHtml(businessAddress) : ""; const safeBusinessAddress = businessAddress ? escapeHtml(businessAddress) : "";
const safeTimeFrom = escapeHtml(timeFrom); const safeTimeFrom = escapeHtml(timeFrom);
const safeTimeTo = escapeHtml(timeTo); const safeTimeTo = escapeHtml(timeTo);
const safeSpotLabel = spotLabel ? escapeHtml(spotLabel) : "";
const spotChangedNote = spotChanged
? "Ihr Wunschplatz war bei Zahlungseingang leider schon vergeben – wir haben Ihnen diesen Platz reserviert."
: "";
const safeMyCoursesUrl = sanitizeUrl(myCoursesUrl); const safeMyCoursesUrl = sanitizeUrl(myCoursesUrl);
const safeCoverImageUrl = coverImageUrl ? sanitizeUrl(coverImageUrl) : null; const safeCoverImageUrl = coverImageUrl ? sanitizeUrl(coverImageUrl) : null;
@ -95,6 +105,18 @@ export function generateBookingConfirmationEmail({
{ label: "Anbieter", valueHtml: safeBusinessName }, { label: "Anbieter", valueHtml: safeBusinessName },
{ label: "Termin", valueHtml: `${formattedDate}<br />${safeTimeFrom} – ${safeTimeTo} Uhr` }, { label: "Termin", valueHtml: `${formattedDate}<br />${safeTimeFrom} – ${safeTimeTo} Uhr` },
...(safeBusinessAddress ? [{ label: "Ort", valueHtml: safeBusinessAddress }] : []), ...(safeBusinessAddress ? [{ label: "Ort", valueHtml: safeBusinessAddress }] : []),
...(safeSpotLabel
? [
{
label: "Ihr Platz",
valueHtml: `<strong>${safeSpotLabel}</strong>${
spotChangedNote
? `<br /><span style="color: #666666; font-size: 13px;">${spotChangedNote}</span>`
: ""
}`,
},
]
: []),
], ],
})} })}
@ -138,7 +160,7 @@ vielen Dank für Ihre Buchung. Ihre Zahlung wurde erfolgreich verarbeitet.
Kurs: ${courseName} Kurs: ${courseName}
Anbieter: ${businessName} Anbieter: ${businessName}
Termin: ${formattedDate}, ${timeFrom} – ${timeTo} Uhr Termin: ${formattedDate}, ${timeFrom} – ${timeTo} Uhr
${businessAddress ? `Ort: ${businessAddress}\n` : ""}Bezahlt: ${formattedPrice} ${businessAddress ? `Ort: ${businessAddress}\n` : ""}${spotLabel ? `Ihr Platz: ${spotLabel}\n` : ""}${spotChangedNote ? `${spotChangedNote}\n` : ""}Bezahlt: ${formattedPrice}
Wir wünschen Ihnen viel Spaß im Kurs! Wir wünschen Ihnen viel Spaß im Kurs!

View file

@ -19,6 +19,8 @@ import {
validateDiscountCode, validateDiscountCode,
calculateDiscountedPriceCents, calculateDiscountedPriceCents,
} from "@/src/modules/settings/lib/discount-validation"; } from "@/src/modules/settings/lib/discount-validation";
import { bookableSpots, parseFloorPlan, type FloorPlan } from "@/src/modules/rooms/lib/floor-plan";
import { getBookedSpotIds, getHeldSpotIds, spotHolderKey } from "../lib/course-spots";
export type UserBooking = { export type UserBooking = {
bookingId: string; bookingId: string;
@ -43,6 +45,8 @@ export type UserBooking = {
trainers: string[]; trainers: string[];
trainersMap: Record<string, { label: string; pbSrc?: string }>; trainersMap: Record<string, { label: string; pbSrc?: string }>;
paymentStatus: string; paymentStatus: string;
/** Gebuchter Platz bei Kursen mit Platzwahl, z.B. "Reformer 3". */
spotLabel: string | null;
isPast: boolean; isPast: boolean;
businessName: string; businessName: string;
/** Kontaktdaten des Standorts für den Detail-Dialog (Ticket: Email + Ort anzeigen). */ /** Kontaktdaten des Standorts für den Detail-Dialog (Ticket: Email + Ort anzeigen). */
@ -68,6 +72,7 @@ export async function getUserBookings(): Promise<
select: { select: {
id: true, id: true,
paymentStatus: true, paymentStatus: true,
spotLabel: true,
course: { course: {
select: { select: {
id: true, id: true,
@ -158,6 +163,7 @@ export async function getUserBookings(): Promise<
trainers: b.course.trainers, trainers: b.course.trainers,
trainersMap, trainersMap,
paymentStatus: b.paymentStatus, paymentStatus: b.paymentStatus,
spotLabel: b.spotLabel,
isPast: courseStart < now, isPast: courseStart < now,
businessName: b.course.business?.name ?? branding.companyName, businessName: b.course.business?.name ?? branding.companyName,
businessAddress: b.course.business?.address ?? null, businessAddress: b.course.business?.address ?? null,
@ -315,27 +321,85 @@ export async function getCourseSummaryForConfirmation(courseId: string, sessionI
// die Anzeige auf den regulären Kurspreis zurück. // die Anzeige auf den regulären Kurspreis zurück.
let paidPrice = course.price; let paidPrice = course.price;
let discountAmount: number | null = null; let discountAmount: number | null = null;
let spotLabel: string | null = null;
if (sessionId) { if (sessionId) {
// findFirst statt findUnique mit stripeSessionId allein: eine URL, die // findFirst statt findUnique mit stripeSessionId allein: eine URL, die
// courseId von Kurs B mit der session_id von Kurs A kombiniert, würde // courseId von Kurs B mit der session_id von Kurs A kombiniert, würde
// sonst Kurs B mit den Zahlungsdaten von Kurs A anzeigen. // sonst Kurs B mit den Zahlungsdaten von Kurs A anzeigen.
const booking = await prisma.courseBooking.findFirst({ const booking = await prisma.courseBooking.findFirst({
where: { stripeSessionId: sessionId, courseId }, where: { stripeSessionId: sessionId, courseId },
select: { amountPaid: true, discountAmount: true }, select: { amountPaid: true, discountAmount: true, spotLabel: true },
}); });
if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) { if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) {
paidPrice = booking.amountPaid / 100; paidPrice = booking.amountPaid / 100;
discountAmount = booking.discountAmount !== null ? booking.discountAmount / 100 : null; discountAmount = booking.discountAmount !== null ? booking.discountAmount / 100 : null;
} }
spotLabel = booking?.spotLabel ?? null;
} }
return { success: true as const, course, paidPrice, discountAmount }; return { success: true as const, course, paidPrice, discountAmount, spotLabel };
} catch (error) { } catch (error) {
console.error("Error fetching course summary:", error); console.error("Error fetching course summary:", error);
return { success: false as const, error: "Fehler beim Laden des Kurses" }; return { success: false as const, error: "Fehler beim Laden des Kurses" };
} }
} }
export type CourseSpotMap = {
floorPlan: FloorPlan;
/** Bezahlt gebucht oder gerade von jemand anderem im Checkout gehalten. */
takenSpotIds: string[];
/** Platz der eigenen bezahlten Buchung des eingeloggten Kunden. */
mySpotId: string | null;
};
/**
* Grundriss und Live-Belegung für die Platzwahl im Buchungsdialog. Bewusst
* ungecacht (die Kursliste ist 30 s gecacht) - wer den Dialog öffnet, soll
* keinen Platz wählen, der seit Sekunden weg ist. null = der Kurs wird ohne
* Platzwahl gebucht. Verrät nicht, WER auf einem Platz liegt.
*/
export async function getCourseSpotMap(courseId: string): Promise<CourseSpotMap | null> {
try {
const course = await prisma.course.findFirst({
where: { id: courseId, status: "published", business: { isPublic: true } },
select: { spotBooking: true, room: true },
});
if (!course?.spotBooking || !course.room) return null;
const room = await prisma.room.findUnique({
where: { id: course.room },
select: { floorPlan: true },
});
const floorPlan = parseFloorPlan(room?.floorPlan);
if (!floorPlan || bookableSpots(floorPlan).length === 0) return null;
const userData = await getUserData();
const userId = userData && isUser(userData) ? userData.id : null;
const [booked, held, myBooking] = await Promise.all([
getBookedSpotIds(prisma, courseId),
getHeldSpotIds(
prisma,
courseId,
userId ? { exceptHolderKey: spotHolderKey({ userId }) } : {}
),
userId
? prisma.courseBooking.findFirst({
where: { courseId, userId, paymentStatus: "paid" },
select: { spotId: true },
})
: null,
]);
const mySpotId = myBooking?.spotId ?? null;
const takenSpotIds = [...new Set([...booked, ...held])].filter((id) => id !== mySpotId);
return { floorPlan, takenSpotIds, mySpotId };
} catch (error) {
console.error("Error fetching course spot map:", error);
return null;
}
}
export async function getBusinessBySlug(slug: string) { export async function getBusinessBySlug(slug: string) {
const fetch = unstable_cache( const fetch = unstable_cache(
async () => { async () => {

View file

@ -20,9 +20,47 @@ import {
TooManyInstancesError, TooManyInstancesError,
type FrequencyType, type FrequencyType,
} from "../utils/generate-instances"; } from "../utils/generate-instances";
import { bookableSpots, parseFloorPlan } from "@/src/modules/rooms/lib/floor-plan";
type StandingOrderScope = "this" | "this_and_following" | "all"; type StandingOrderScope = "this" | "this_and_following" | "all";
/**
* Platzwahl braucht einen Raum mit Geräten, und es dürfen nicht mehr
* Teilnehmer zugelassen sein, als es Plätze gibt - sonst bekäme der letzte
* Kunde zwar eine Zahlung abgebucht, aber keinen Platz. Gibt eine
* Fehlermeldung zurück oder null, wenn alles passt.
*
* Liest nur Räume des eigenen Managers - sonst ließe sich mit einer fremden
* Raum-ID dessen Platzanzahl auslesen.
*/
async function validateSpotBooking(
data: { spotBooking?: boolean; room?: string; maxParticipants?: number },
managerId: string
): Promise<{ error: string; field: "room" | "maxParticipants" } | null> {
if (!data.spotBooking) return null;
if (!data.room) {
return { error: "Für die Platzwahl braucht der Kurs einen Raum", field: "room" };
}
const room = await prisma.room.findFirst({
where: { id: data.room, createdBy: managerId },
select: { floorPlan: true },
});
const spotCount = bookableSpots(parseFloorPlan(room?.floorPlan)).length;
if (spotCount === 0) {
return {
error: "Dieser Raum hat keinen Grundriss mit Geräten - Platzwahl ist hier nicht möglich",
field: "room",
};
}
if (data.maxParticipants && data.maxParticipants > spotCount) {
return {
error: `Höchstens ${spotCount} Teilnehmer - so viele Plätze hat der Raum`,
field: "maxParticipants",
};
}
return null;
}
/** /**
* Erstellt einen neuen Kurs in der Datenbank * Erstellt einen neuen Kurs in der Datenbank
* *
@ -75,6 +113,15 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
if (!creatorId) if (!creatorId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" }; return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
const spotError = await validateSpotBooking(validatedData, creatorId);
if (spotError) {
return {
success: false,
error: spotError.error,
fieldErrors: { [spotError.field]: [spotError.error] },
};
}
// Schritt 6: Kurs erstellen — bei Dauerauftrag Template + Instanzen, sonst einzelner Kurs // Schritt 6: Kurs erstellen — bei Dauerauftrag Template + Instanzen, sonst einzelner Kurs
const commonFields = { const commonFields = {
name: validatedData.name || "", name: validatedData.name || "",
@ -88,6 +135,7 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
coverImage: validatedData.coverImage ?? null, coverImage: validatedData.coverImage ?? null,
maxParticipants: validatedData.maxParticipants || 0, maxParticipants: validatedData.maxParticipants || 0,
price: validatedData.price ?? 0, price: validatedData.price ?? 0,
spotBooking: validatedData.spotBooking ?? false,
frequency: validatedData.frequency || null, frequency: validatedData.frequency || null,
weekdays: validatedData.weekdays || [], weekdays: validatedData.weekdays || [],
weekdayTimings: validatedData.weekdayTimings weekdayTimings: validatedData.weekdayTimings
@ -458,6 +506,15 @@ export async function updateCourse(
const validatedData = validation.data; const validatedData = validation.data;
const spotError = await validateSpotBooking(validatedData, managerId);
if (spotError) {
return {
success: false,
error: spotError.error,
fieldErrors: { [spotError.field]: [spotError.error] },
};
}
// Felder, die auf Instanzen angewendet werden (ohne date/timeFrom/timeTo) // Felder, die auf Instanzen angewendet werden (ohne date/timeFrom/timeTo)
const sharedUpdateFields = { const sharedUpdateFields = {
name: validatedData.name || "", name: validatedData.name || "",
@ -469,6 +526,7 @@ export async function updateCourse(
coverImage: validatedData.coverImage ?? null, coverImage: validatedData.coverImage ?? null,
maxParticipants: validatedData.maxParticipants || 0, maxParticipants: validatedData.maxParticipants || 0,
price: validatedData.price ?? 0, price: validatedData.price ?? 0,
spotBooking: validatedData.spotBooking ?? false,
status, status,
...(validatedData.businessId !== undefined ...(validatedData.businessId !== undefined
? { businessId: validatedData.businessId || null } ? { businessId: validatedData.businessId || null }

View file

@ -0,0 +1,226 @@
import type { prisma as prismaClient } from "@/src/lib/prisma";
import {
bookableSpots,
parseFloorPlan,
spotDisplayName,
type FloorPlanItem,
} from "@/src/modules/rooms/lib/floor-plan";
/**
* Platzwahl bei Kursen (z.B. Reformer im Pilates-Studio).
*
* Ablauf:
* 1. Checkout-Start: holdSpot() hält den gewählten Platz für die Dauer der
* Stripe-Session frei (CourseSpotHold), damit kein zweiter Kunde mit
* demselben Platz zur Kasse geht. Startet dieselbe Person erneut, beendet
* die Checkout-Route vorher ihre alte, noch offene Session.
* 2. Webhook nach der Zahlung: assignSpot() schreibt den Platz an die Buchung.
* Verbindlich ist nur, was bezahlt ist - war der Wunschplatz inzwischen
* doch vergeben (Hold abgelaufen, SEPA-Zahlung kam Tage später), bekommt
* der Kunde den nächsten freien Platz statt einer Erstattung.
* 3. Danach oder bei abgelaufener Session: releaseSpotHold().
*
* Belegt ist ein Platz durch eine "paid"-Buchung mit dieser spotId - dieselbe
* Definition wie bei der Teilnehmerzahl. Stornos geben den Platz damit von
* selbst frei, ohne dass jemand spotId zurücksetzen muss.
*/
type PrismaOrTx =
| typeof prismaClient
| Parameters<Parameters<typeof prismaClient.$transaction>[0]>[0];
/** Stripe erlaubt für expires_at frühestens 30 Minuten - so lange hält der Platz. */
export const SPOT_HOLD_MINUTES = 30;
/**
* Nach einem abgeschlossenen, aber noch nicht bezahlten Checkout (SEPA) bleibt
* der Platz so lange reserviert, bis die Zahlung ankommt oder scheitert.
*/
export const DELAYED_PAYMENT_HOLD_DAYS = 14;
export function spotHolderKey(params: { userId?: string | null; guestEmail?: string | null }) {
if (params.userId) return `user:${params.userId}`;
return `guest:${(params.guestEmail ?? "").trim().toLowerCase()}`;
}
/**
* Die buchbaren Plätze eines Kurses, oder null, wenn ohne Platzwahl gebucht
* wird: Platzwahl ist aus, kein Raum, oder der Raum hat (noch) keine Geräte.
*/
export async function getCourseSpots(
db: PrismaOrTx,
course: { spotBooking?: boolean | null; room?: string | null }
): Promise<FloorPlanItem[] | null> {
if (!course.spotBooking || !course.room) return null;
const room = await db.room.findUnique({
where: { id: course.room },
select: { floorPlan: true },
});
const spots = bookableSpots(parseFloorPlan(room?.floorPlan));
return spots.length > 0 ? spots : null;
}
/** Plätze mit bezahlter Buchung. */
export async function getBookedSpotIds(db: PrismaOrTx, courseId: string): Promise<Set<string>> {
const rows = await db.courseBooking.findMany({
where: { courseId, paymentStatus: "paid", spotId: { not: null } },
select: { spotId: true },
});
return new Set(rows.map((r) => r.spotId!));
}
/**
* Plätze, die gerade jemand im Checkout hält. exceptHolderKey blendet die
* eigenen offenen Checkouts aus - wer den Checkout abbricht und zurückkommt,
* soll seinen Platz nicht als belegt sehen (der nächste Checkout ersetzt ihn).
* Ein eigener abgeschlossener Checkout (SEPA unterwegs) bleibt belegt.
*/
export async function getHeldSpotIds(
db: PrismaOrTx,
courseId: string,
opts: { exceptHolderKey?: string; exceptHoldId?: string } = {}
): Promise<Set<string>> {
const rows = await db.courseSpotHold.findMany({
where: {
courseId,
expiresAt: { gt: new Date() },
...(opts.exceptHolderKey
? { NOT: { holderKey: opts.exceptHolderKey, status: "checkout" } }
: {}),
...(opts.exceptHoldId ? { id: { not: opts.exceptHoldId } } : {}),
},
select: { spotId: true },
});
return new Set(rows.map((r) => r.spotId));
}
export type HoldSpotResult = { held: true; holdId: string } | { held: false; reason: string };
/**
* Hält einen Platz für den Checkout frei. Die Eindeutigkeit erzwingt
* @@unique([courseId, spotId]): fordern zwei Kunden gleichzeitig denselben
* Platz an, gewinnt der erste Insert, der zweite bekommt P2002.
*
* Räumt nur abgelaufene Holds weg. Holds derselben Person aus früheren
* Checkouts gibt die Checkout-Route vorher frei, und zwar nur, wenn deren
* Session nachweislich noch offen war - ein bereits bezahlter oder per SEPA
* abgeschlossener Checkout behält seinen Platz.
*/
export async function holdSpot(
db: typeof prismaClient,
params: { courseId: string; spotId: string; holderKey: string; expiresAt: Date }
): Promise<HoldSpotResult> {
const { courseId, spotId, holderKey, expiresAt } = params;
// Abgelaufene Holds wegräumen - sonst blockiert ihr Unique-Key den Platz.
await db.courseSpotHold.deleteMany({
where: { courseId, expiresAt: { lte: new Date() } },
});
const booked = await db.courseBooking.findFirst({
where: { courseId, spotId, paymentStatus: "paid" },
select: { id: true },
});
if (booked) {
return {
held: false,
reason: "Dieser Platz ist bereits vergeben. Bitte wählen Sie einen anderen.",
};
}
try {
const hold = await db.courseSpotHold.create({
data: { courseId, spotId, holderKey, expiresAt },
select: { id: true },
});
return { held: true, holdId: hold.id };
} catch (error) {
if (error instanceof Error && "code" in error && (error as { code: string }).code === "P2002") {
const existing = await db.courseSpotHold.findUnique({
where: { courseId_spotId: { courseId, spotId } },
select: { holderKey: true },
});
return {
held: false,
reason:
existing?.holderKey === holderKey
? "Für diesen Platz läuft bereits Ihre Zahlung. Bitte warten Sie die Bestätigung ab."
: "Dieser Platz wird gerade von jemand anderem gebucht. Bitte wählen Sie einen anderen.",
};
}
throw error;
}
}
/** Offene Checkouts einer Person für diesen Kurs - die Checkout-Route ersetzt sie. */
export async function getOpenCheckoutHolds(
db: PrismaOrTx,
courseId: string,
holderKey: string
): Promise<{ id: string; stripeSessionId: string | null }[]> {
return db.courseSpotHold.findMany({
where: { courseId, holderKey, status: "checkout" },
select: { id: true, stripeSessionId: true },
});
}
/** Verknüpft den Hold mit seiner Session, sobald Stripe sie angelegt hat. */
export async function attachSessionToHold(
db: PrismaOrTx,
holdId: string,
stripeSessionId: string
): Promise<void> {
await db.courseSpotHold.updateMany({ where: { id: holdId }, data: { stripeSessionId } });
}
export async function releaseSpotHold(db: PrismaOrTx, holdId: string): Promise<void> {
await db.courseSpotHold.deleteMany({ where: { id: holdId } });
}
/**
* Checkout abgeschlossen, Zahlung unterwegs (SEPA): Platz länger halten und
* als abgeschlossen markieren, damit ein neuer Checkout derselben Person ihn
* nicht als "alten offenen Checkout" freigibt.
*/
export async function extendSpotHold(db: PrismaOrTx, holdId: string, until: Date): Promise<void> {
await db.courseSpotHold.updateMany({
where: { id: holdId },
data: { expiresAt: until, status: "awaiting_payment" },
});
}
export type AssignedSpot = { spotId: string; spotLabel: string; changed: boolean };
/**
* Bestimmt im Webhook (innerhalb der Kapazitäts-Transaktion) den Platz einer
* bezahlten Buchung. Wunschplatz, wenn er noch frei ist - sonst der erste freie
* Platz, den niemand gerade im Checkout hält. null = kein Platz mehr frei, die
* Buchung ist dann wie bei einem vollen Kurs abzulehnen.
*/
export async function assignSpot(
tx: PrismaOrTx,
params: {
courseId: string;
spots: FloorPlanItem[];
requestedSpotId: string | null;
holdId: string | null;
}
): Promise<AssignedSpot | null> {
const { courseId, spots, requestedSpotId, holdId } = params;
const booked = await getBookedSpotIds(tx, courseId);
const free = spots.filter((s) => !booked.has(s.id));
if (free.length === 0) return null;
let chosen = requestedSpotId ? free.find((s) => s.id === requestedSpotId) : undefined;
if (!chosen) {
const held = await getHeldSpotIds(tx, courseId, holdId ? { exceptHoldId: holdId } : {});
chosen = free.find((s) => !held.has(s.id)) ?? free[0];
}
return {
spotId: chosen.id,
spotLabel: spotDisplayName(chosen),
changed: requestedSpotId !== null && chosen.id !== requestedSpotId,
};
}

View file

@ -53,6 +53,7 @@ export const courseSchema = z
weekdayTimings: weekdayTimingsSchema, weekdayTimings: weekdayTimingsSchema,
endDate: isoDateOptional, endDate: isoDateOptional,
businessId: z.string().optional(), businessId: z.string().optional(),
spotBooking: z.boolean().optional(),
}) })
.refine( .refine(
(data) => { (data) => {
@ -92,6 +93,7 @@ export const publishedCourseSchema = z
weekdayTimings: weekdayTimingsSchema, weekdayTimings: weekdayTimingsSchema,
endDate: isoDateOptional, endDate: isoDateOptional,
businessId: z.string().optional(), businessId: z.string().optional(),
spotBooking: z.boolean().optional(),
}) })
.refine( .refine(
(data) => { (data) => {

View file

@ -25,6 +25,7 @@ export type Course = {
createdBy: string; createdBy: string;
businessId: string | null; businessId: string | null;
coverImage: string | null; coverImage: string | null;
spotBooking: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}; };

View file

@ -1,19 +1,26 @@
"use client"; "use client";
import { useState, useEffect, useSyncExternalStore } from "react"; import { useState, useEffect, useRef, useSyncExternalStore } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Clock, MapPin, Users, Mail, Phone, DoorOpen, Info, X } from "lucide-react"; import { Clock, MapPin, Users, Mail, Phone, DoorOpen, Info, X, LayoutGrid } from "lucide-react";
import { Button } from "@/src/components/ui/button"; import { Button } from "@/src/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/components/ui/dialog"; import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { checkUserBookingStatus, previewDiscountCode } from "../../actions/booking-actions"; import {
checkUserBookingStatus,
getCourseSpotMap,
previewDiscountCode,
type CourseSpotMap,
} from "../../actions/booking-actions";
import { joinWaitlistAsUser } from "../../actions/waitlist-actions"; import { joinWaitlistAsUser } from "../../actions/waitlist-actions";
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast"; import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
import { toast } from "sonner"; import { toast } from "sonner";
import { COURSE_LEVELS } from "../../lib/course-levels"; import { COURSE_LEVELS } from "../../lib/course-levels";
import { formatEuro } from "./course-schedule-shared"; import { formatEuro } from "./course-schedule-shared";
import { SpotPicker } from "@/src/modules/rooms/ui/components/floor-plan/SpotPicker";
import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und // useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben // `true` im Browser. Damit haben Server- und initialer Client-Render denselben
@ -78,6 +85,10 @@ export interface PublicCourse {
roomName?: string | null; roomName?: string | null;
coverImage?: string; coverImage?: string;
trainerProfiles?: TrainerProfile[]; trainerProfiles?: TrainerProfile[];
/** Kunden wählen beim Buchen einen Platz im Grundriss des Raums. */
spotBooking?: boolean;
/** Gebuchter Platz - nur in der Ansicht einer bestehenden Buchung. */
spotLabel?: string | null;
} }
const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" | "lg" }) => { const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" | "lg" }) => {
@ -165,6 +176,13 @@ export const BookingDialog = ({
code: string; code: string;
discountedPrice: number; discountedPrice: number;
} | null>(null); } | null>(null);
// Platzwahl: Grundriss + Belegung, live geladen beim Öffnen. Bleibt null bei
// Kursen ohne Platzwahl - dann läuft die Buchung wie bisher.
const [spotMap, setSpotMap] = useState<CourseSpotMap | null>(null);
const [isLoadingSpots, setIsLoadingSpots] = useState(false);
const [selectedSpotId, setSelectedSpotId] = useState<string | null>(null);
const [highlightPicker, setHighlightPicker] = useState(false);
const spotPickerRef = useRef<HTMLDivElement>(null);
const isAdminView = paymentStatus !== undefined; const isAdminView = paymentStatus !== undefined;
@ -215,6 +233,52 @@ export const BookingDialog = ({
}; };
}, [open, course.id, isAdminView]); }, [open, course.id, isAdminView]);
useEffect(() => {
if (!open || isAdminView || !course.spotBooking) return;
let active = true;
const loadSpots = async () => {
setIsLoadingSpots(true);
try {
const map = await getCourseSpotMap(course.id);
if (!active) return;
setSpotMap(map);
// Eine Auswahl aus einem früheren Öffnen gilt nur, solange der Platz frei ist.
setSelectedSpotId((prev) =>
prev &&
map?.floorPlan.items.some((i) => i.id === prev) &&
!map.takenSpotIds.includes(prev)
? prev
: null
);
} catch {
if (active) setSpotMap(null);
} finally {
if (active) setIsLoadingSpots(false);
}
};
void loadSpots();
return () => {
active = false;
};
}, [open, course.id, course.spotBooking, isAdminView]);
const spotMode = !isAdminView && spotMap !== null;
const selectedSpot = spotMode
? (spotMap.floorPlan.items.find((i) => i.id === selectedSpotId) ?? null)
: null;
const mySpot = spotMode
? (spotMap.floorPlan.items.find((i) => i.id === spotMap.mySpotId) ?? null)
: null;
const needsSpot = spotMode && !selectedSpot;
const showSpotPicker = () => {
spotPickerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
setHighlightPicker(true);
window.setTimeout(() => setHighlightPicker(false), 1200);
};
// Setzt den "Wird gebucht"-Zustand zurück, wenn die Seite nach einem // Setzt den "Wird gebucht"-Zustand zurück, wenn die Seite nach einem
// Stripe-Checkout-Abbruch (Zurück-Navigation) aus dem bfcache wiederhergestellt wird. // Stripe-Checkout-Abbruch (Zurück-Navigation) aus dem bfcache wiederhergestellt wird.
useEffect(() => { useEffect(() => {
@ -251,6 +315,7 @@ export const BookingDialog = ({
try { try {
const body: Record<string, string> = { courseId: course.id }; const body: Record<string, string> = { courseId: course.id };
if (appliedDiscount) body.discountCode = appliedDiscount.code; if (appliedDiscount) body.discountCode = appliedDiscount.code;
if (selectedSpot) body.spotId = selectedSpot.id;
if (opts?.asGuest) { if (opts?.asGuest) {
const normalizedEmail = guestEmail.trim().toLowerCase(); const normalizedEmail = guestEmail.trim().toLowerCase();
if (!EMAIL_RE.test(normalizedEmail)) { if (!EMAIL_RE.test(normalizedEmail)) {
@ -268,7 +333,12 @@ export const BookingDialog = ({
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
let data: { url?: string; error?: string; isGuestRequired?: boolean } = {}; let data: {
url?: string;
error?: string;
isGuestRequired?: boolean;
spotUnavailable?: boolean;
} = {};
try { try {
data = (await response.json()) as typeof data; data = (await response.json()) as typeof data;
} catch { } catch {
@ -288,6 +358,17 @@ export const BookingDialog = ({
return; return;
} }
// Platz inzwischen weg (oder Grundriss geändert): Belegung neu laden,
// damit der Kunde direkt einen anderen wählen kann.
if (data.spotUnavailable) {
toast.error(data.error || "Dieser Platz ist nicht mehr frei.");
setSelectedSpotId(null);
setSpotMap(await getCourseSpotMap(course.id));
setIsSubmitting(false);
showSpotPicker();
return;
}
if (!response.ok || !data.url) { if (!response.ok || !data.url) {
toast.error(data.error || "Ein Fehler ist aufgetreten"); toast.error(data.error || "Ein Fehler ist aufgetreten");
setIsSubmitting(false); setIsSubmitting(false);
@ -407,6 +488,15 @@ export const BookingDialog = ({
</div> </div>
)} )}
{isAdminView && course.spotLabel && (
<div className="flex items-center gap-2.5">
<LayoutGrid size={15} className="text-gray-400 shrink-0" />
<span>
<b>Platz:</b> {course.spotLabel}
</span>
</div>
)}
{business?.address && ( {business?.address && (
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<MapPin size={15} className="text-gray-400 shrink-0" /> <MapPin size={15} className="text-gray-400 shrink-0" />
@ -442,6 +532,40 @@ export const BookingDialog = ({
</div> </div>
)} )}
{spotMode && (isBooked ? mySpot : !isFull) && (
<div
ref={spotPickerRef}
className={`flex flex-col gap-3 rounded-xl border p-3 sm:p-4 transition-colors ${
highlightPicker ? "border-gray-900 bg-gray-50" : "border-gray-200"
}`}
>
<div className="flex items-start gap-2.5">
<LayoutGrid size={15} className="text-gray-400 shrink-0 mt-0.5" />
<div>
{isBooked ? (
<b>Ihr Platz: {spotDisplayName(mySpot!)}</b>
) : (
<>
<b>Platz wählen</b>
<p className="text-gray-500 mt-0.5">
Tippen Sie auf ein freies Gerät – es ist für Sie reserviert, sobald die
Zahlung durch ist.
</p>
</>
)}
</div>
</div>
<SpotPicker
floorPlan={spotMap.floorPlan}
takenSpotIds={spotMap.takenSpotIds}
selectedSpotId={selectedSpotId}
mySpotId={spotMap.mySpotId}
onSelect={isBooked ? undefined : setSelectedSpotId}
accentColor={accentColor}
/>
</div>
)}
{course.description && ( {course.description && (
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
<Info size={15} className="text-gray-400 shrink-0 mt-0.5" /> <Info size={15} className="text-gray-400 shrink-0 mt-0.5" />
@ -505,6 +629,17 @@ export const BookingDialog = ({
Kurs + {formatEuro(serviceFee)} Servicegebühr Kurs + {formatEuro(serviceFee)} Servicegebühr
</span> </span>
)} )}
{spotMode && !isBooked && !isFull && (
<span className="text-xs text-gray-700 mt-0.5">
{selectedSpot ? (
<>
Ihr Platz: <b>{spotDisplayName(selectedSpot)}</b>
</>
) : (
"Noch kein Platz gewählt"
)}
</span>
)}
</div> </div>
<div className="flex items-center gap-3 w-full sm:w-auto"> <div className="flex items-center gap-3 w-full sm:w-auto">
{/* Admin-Ansicht: nur Status-Badge + Schließen */} {/* Admin-Ansicht: nur Status-Badge + Schließen */}
@ -543,7 +678,7 @@ export const BookingDialog = ({
> >
Schließen Schließen
</Button> </Button>
{isCheckingStatus ? ( {isCheckingStatus || isLoadingSpots ? (
<Button disabled className="flex-1 sm:flex-initial"> <Button disabled className="flex-1 sm:flex-initial">
Lade… Lade…
</Button> </Button>
@ -570,12 +705,12 @@ export const BookingDialog = ({
// Gäste buchen über das Formular darunter - ein zweiter // Gäste buchen über das Formular darunter - ein zweiter
// "Buchen"-Button daneben wäre nur verwirrend. // "Buchen"-Button daneben wäre nur verwirrend.
<Button <Button
onClick={() => handleBooking()} onClick={() => (needsSpot ? showSpotPicker() : handleBooking())}
disabled={isSubmitting} disabled={isSubmitting}
className="flex-1 sm:flex-initial" className="flex-1 sm:flex-initial"
style={accentButtonStyle} style={accentButtonStyle}
> >
{isSubmitting ? "Wird gebucht…" : "Buchen"} {isSubmitting ? "Wird gebucht…" : needsSpot ? "Platz wählen" : "Buchen"}
</Button> </Button>
)} )}
</> </>
@ -629,12 +764,16 @@ export const BookingDialog = ({
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400" className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
/> />
<Button <Button
onClick={() => handleBooking({ asGuest: true })} onClick={() => (needsSpot ? showSpotPicker() : handleBooking({ asGuest: true }))}
disabled={isSubmitting || !EMAIL_RE.test(guestEmail.trim().toLowerCase())} disabled={
isSubmitting ||
isLoadingSpots ||
(!needsSpot && !EMAIL_RE.test(guestEmail.trim().toLowerCase()))
}
className="w-full" className="w-full"
style={accentButtonStyle} style={accentButtonStyle}
> >
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"} {isSubmitting ? "Wird gebucht…" : needsSpot ? "Platz wählen" : "Als Gast buchen"}
</Button> </Button>
</div> </div>
)} )}

View file

@ -50,6 +50,7 @@ function toPublicCourse(b: UserBooking): PublicCourse {
currentParticipants: b.currentParticipants, currentParticipants: b.currentParticipants,
description: b.description, description: b.description,
roomName: b.roomName, roomName: b.roomName,
spotLabel: b.spotLabel,
coverImage: b.coverImage ?? undefined, coverImage: b.coverImage ?? undefined,
trainerProfiles: b.trainerProfiles, trainerProfiles: b.trainerProfiles,
}; };
@ -182,9 +183,12 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
{/* Datum + Raum */} {/* Datum + Raum */}
<div className="flex items-center justify-between text-sm text-gray-500"> <div className="flex items-center justify-between text-sm text-gray-500">
<span>{dateFormatted}</span> <span>{dateFormatted}</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1 min-w-0">
<MapPin size={13} className="shrink-0" /> <MapPin size={13} className="shrink-0" />
{booking.roomName} <span className="truncate">
{booking.roomName}
{booking.spotLabel && ` · ${booking.spotLabel}`}
</span>
</span> </span>
</div> </div>

View file

@ -24,6 +24,7 @@ import {
FileText, FileText,
RefreshCw, RefreshCw,
Check, Check,
LayoutGrid,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import DeleteDialog from "@/src/components/layout/DeleteDialog"; import DeleteDialog from "@/src/components/layout/DeleteDialog";
@ -49,6 +50,7 @@ import {
StandingOrderScopeDialog, StandingOrderScopeDialog,
type StandingOrderScope, type StandingOrderScope,
} from "../components/StandingOrderScopeDialog"; } from "../components/StandingOrderScopeDialog";
import { bookableSpots, parseFloorPlan } from "@/src/modules/rooms/lib/floor-plan";
const LEVELS = [ const LEVELS = [
{ value: "any", label: "Jedes Niveau" }, { value: "any", label: "Jedes Niveau" },
@ -128,6 +130,9 @@ const CourseCreateView = ({
const [maxParticipants, setMaxParticipants] = useState( const [maxParticipants, setMaxParticipants] = useState(
isEdit ? initialData!.maxParticipants?.toString() || "" : "" isEdit ? initialData!.maxParticipants?.toString() || "" : ""
); );
// Neue Kurse in einem Raum mit Geräten bekommen standardmäßig Platzwahl -
// der Schalter erscheint ohnehin nur, wenn der Raum einen Grundriss hat.
const [spotBooking, setSpotBooking] = useState(isEdit ? initialData!.spotBooking : true);
const [isStandingOrder, setIsStandingOrder] = useState( const [isStandingOrder, setIsStandingOrder] = useState(
isEdit ? initialData!.isStandingOrder || false : false isEdit ? initialData!.isStandingOrder || false : false
); );
@ -169,7 +174,9 @@ const CourseCreateView = ({
const [isPriceFocused, setIsPriceFocused] = useState(false); const [isPriceFocused, setIsPriceFocused] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [rooms, setRooms] = useState<Array<{ value: string; label: string }>>([]); const [rooms, setRooms] = useState<Array<{ value: string; label: string; spotCount: number }>>(
[]
);
const [isLoadingRooms, setIsLoadingRooms] = useState(true); const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [trainers, setTrainers] = useState<Array<{ value: string; label: string }>>([]); const [trainers, setTrainers] = useState<Array<{ value: string; label: string }>>([]);
const [isLoadingTrainers, setIsLoadingTrainers] = useState(true); const [isLoadingTrainers, setIsLoadingTrainers] = useState(true);
@ -186,6 +193,21 @@ const CourseCreateView = ({
const isStandingOrderRelated = const isStandingOrderRelated =
isEdit && !!(initialData?.parentCourseId || initialData?.isStandingOrder); isEdit && !!(initialData?.parentCourseId || initialData?.isStandingOrder);
const roomSpotCount = rooms.find((r) => r.value === selectedRoom)?.spotCount ?? 0;
const spotBookingActive = spotBooking && roomSpotCount > 0;
// Mit Platzwahl gibt es höchstens so viele Teilnehmer wie Plätze - beim
// Einschalten oder Raumwechsel die Zahl gleich passend setzen.
const fitParticipantsToSpots = (spots: number) => {
const current = parseInt(maxParticipants);
if (spots > 0 && (isNaN(current) || current > spots)) setMaxParticipants(String(spots));
};
const handleRoomSelect = (roomId: string) => {
setSelectedRoom(roomId);
if (spotBooking) fitParticipantsToSpots(rooms.find((r) => r.value === roomId)?.spotCount ?? 0);
};
const { const {
deleteDialogOpen, deleteDialogOpen,
setDeleteDialogOpen, setDeleteDialogOpen,
@ -225,6 +247,7 @@ const CourseCreateView = ({
const roomOptions = result.rooms.map((room) => ({ const roomOptions = result.rooms.map((room) => ({
value: room.id, value: room.id,
label: room.name, label: room.name,
spotCount: bookableSpots(parseFloorPlan(room.floorPlan)).length,
})); }));
setRooms(roomOptions); setRooms(roomOptions);
} else { } else {
@ -291,6 +314,8 @@ const CourseCreateView = ({
newErrors.maxParticipants = "Mindestens 1 Teilnehmer erforderlich"; newErrors.maxParticipants = "Mindestens 1 Teilnehmer erforderlich";
} else if (maxPart > 100) { } else if (maxPart > 100) {
newErrors.maxParticipants = "Maximal 100 Teilnehmer erlaubt"; newErrors.maxParticipants = "Maximal 100 Teilnehmer erlaubt";
} else if (spotBookingActive && maxPart > roomSpotCount) {
newErrors.maxParticipants = `Höchstens ${roomSpotCount} – so viele Plätze hat der Raum`;
} }
const priceValue = parseFloat(price); const priceValue = parseFloat(price);
@ -369,6 +394,9 @@ const CourseCreateView = ({
weekdayTimings: weekdays.cleanedTimings, weekdayTimings: weekdays.cleanedTimings,
endDate: endDate || undefined, endDate: endDate || undefined,
businessId: selectedBusinessId || undefined, businessId: selectedBusinessId || undefined,
// Solange die Räume laden, ist die Platzanzahl unbekannt - dann den
// bisherigen Wert behalten statt die Platzwahl versehentlich abzuschalten.
spotBooking: isLoadingRooms ? spotBooking : spotBookingActive,
}); });
const doSubmit = async ( const doSubmit = async (
@ -756,7 +784,7 @@ const CourseCreateView = ({
<GenericDropdown <GenericDropdown
label="Raum" label="Raum"
selected={selectedRoom} selected={selectedRoom}
onSelect={setSelectedRoom} onSelect={handleRoomSelect}
options={rooms} options={rooms}
error={errors.room} error={errors.room}
disabled={isLoadingRooms} disabled={isLoadingRooms}
@ -772,6 +800,42 @@ const CourseCreateView = ({
/> />
)} )}
</div> </div>
{roomSpotCount > 0 && (
<button
type="button"
onClick={() => {
const next = !spotBooking;
setSpotBooking(next);
if (next) fitParticipantsToSpots(roomSpotCount);
}}
aria-pressed={spotBooking}
className={`flex items-center gap-3 w-full rounded-xl border p-4 text-left transition-all cursor-pointer ${
spotBooking
? "border-[#c9a84c]/50 bg-[#c9a84c]/5"
: "border-gray-200 bg-white/40 hover:border-gray-300 hover:bg-white/60"
}`}
>
<div
className={`flex items-center justify-center size-8 rounded-full border-2 transition-all shrink-0 ${
spotBooking ? "border-[#c9a84c] bg-[#c9a84c]" : "border-gray-300 bg-white"
}`}
>
{spotBooking && <Check className="size-4 text-white" />}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">Platzwahl beim Buchen</p>
<p className="text-xs text-gray-500 mt-0.5">
Kunden wählen ihr Gerät im Grundriss – {roomSpotCount}{" "}
{roomSpotCount === 1 ? "Platz" : "Plätze"} in diesem Raum
</p>
</div>
<LayoutGrid
className={`ml-auto size-4 shrink-0 transition-colors ${
spotBooking ? "text-[#c9a84c]" : "text-gray-300"
}`}
/>
</button>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<InputComponent <InputComponent
@ -818,8 +882,14 @@ const CourseCreateView = ({
value={maxParticipants} value={maxParticipants}
onChange={(e) => setMaxParticipants(e.target.value)} onChange={(e) => setMaxParticipants(e.target.value)}
/> />
{errors.maxParticipants && ( {errors.maxParticipants ? (
<p className="text-red-500 text-sm mt-1">{errors.maxParticipants}</p> <p className="text-red-500 text-sm mt-1">{errors.maxParticipants}</p>
) : (
spotBookingActive && (
<p className="text-gray-500 text-xs mt-1">
Höchstens {roomSpotCount} – so viele Plätze hat der Raum
</p>
)
)} )}
</div> </div>
</div> </div>

View file

@ -32,6 +32,8 @@ export type KundeBooking = {
trainers: string[]; trainers: string[];
trainersMap: Record<string, { label: string; pbSrc?: string }>; trainersMap: Record<string, { label: string; pbSrc?: string }>;
paymentStatus: string; paymentStatus: string;
/** Gebuchter Platz bei Kursen mit Platzwahl, z.B. "Reformer 3". */
spotLabel: string | null;
isPast: boolean; isPast: boolean;
businessName: string; businessName: string;
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string }; businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
@ -84,6 +86,7 @@ export async function getKundeDetail(
select: { select: {
id: true, id: true,
paymentStatus: true, paymentStatus: true,
spotLabel: true,
course: { course: {
select: { select: {
id: true, id: true,
@ -178,6 +181,7 @@ export async function getKundeDetail(
trainers: b.course.trainers, trainers: b.course.trainers,
trainersMap, trainersMap,
paymentStatus: b.paymentStatus, paymentStatus: b.paymentStatus,
spotLabel: b.spotLabel,
isPast: courseStart < now, isPast: courseStart < now,
businessName: b.course.business?.name ?? branding.companyName, businessName: b.course.business?.name ?? branding.companyName,
businessBranding: { businessBranding: {

View file

@ -46,6 +46,7 @@ function toPublicCourse(b: KundeBooking): PublicCourse {
currentParticipants: b.currentParticipants, currentParticipants: b.currentParticipants,
description: b.description, description: b.description,
roomName: b.roomName, roomName: b.roomName,
spotLabel: b.spotLabel,
coverImage: b.coverImage ?? undefined, coverImage: b.coverImage ?? undefined,
trainerProfiles: b.trainerProfiles, trainerProfiles: b.trainerProfiles,
}; };
@ -132,9 +133,12 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
{/* Datum + Raum */} {/* Datum + Raum */}
<div className="flex items-center justify-between text-sm text-gray-500"> <div className="flex items-center justify-between text-sm text-gray-500">
<span>{dateFormatted}</span> <span>{dateFormatted}</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1 min-w-0">
<MapPin size={13} className="shrink-0" /> <MapPin size={13} className="shrink-0" />
{booking.roomName} <span className="truncate">
{booking.roomName}
{booking.spotLabel && ` · ${booking.spotLabel}`}
</span>
</span> </span>
</div> </div>

View file

@ -12,6 +12,20 @@ import { prisma } from "@/src/lib/prisma";
import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser"; import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
import { roomSchema, type RoomFormData } from "../schemas/room-schema"; import { roomSchema, type RoomFormData } from "../schemas/room-schema";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { Prisma } from "@/src/generated/prisma/client";
import { bookableSpots, parseFloorPlan, spotDisplayName, type FloorPlan } from "../lib/floor-plan";
import { berlinDayStart, dayKey } from "@/src/modules/courses/lib/course-days";
import { refreshPublicCourses } from "@/src/modules/courses/lib/public-courses-cache";
/** Ein Grundriss ohne Geräte wird als "kein Grundriss" gespeichert. */
function floorPlanColumn(plan: FloorPlan | null | undefined) {
return plan && plan.items.length > 0 ? (plan as Prisma.InputJsonValue) : Prisma.JsonNull;
}
function joinNames(names: string[]): string {
if (names.length <= 1) return names.join("");
return `${names.slice(0, -1).join(", ")} und ${names[names.length - 1]}`;
}
/** /**
* Erstellt einen neuen Raum in der Datenbank * Erstellt einen neuen Raum in der Datenbank
@ -68,6 +82,7 @@ export async function createRoom(data: RoomFormData) {
data: { data: {
name: validatedData.name, name: validatedData.name,
createdBy: creatorId, createdBy: creatorId,
floorPlan: floorPlanColumn(validatedData.floorPlan),
}, },
}); });
@ -314,22 +329,90 @@ export async function updateRoom(roomId: string, data: RoomFormData) {
} }
const validatedData = validation.data; const validatedData = validation.data;
const newPlan =
validatedData.floorPlan && validatedData.floorPlan.items.length > 0
? validatedData.floorPlan
: null;
const todayStart = berlinDayStart(dayKey(new Date()));
// Schritt 8: Raum in der Datenbank aktualisieren // Schritt 9: Gebuchte Plätze schützen. Kommende Buchungen zeigen auf die
// ID eines Geräts und tragen seinen Namen ("Reformer 3") - ein solches
// Gerät darf verschoben, aber nicht entfernt oder umbenannt werden, sonst
// stünde der Kunde vor einem Platz, den es so nicht mehr gibt.
const newById = new Map((newPlan?.items ?? []).map((item) => [item.id, item]));
const touchedSpots = bookableSpots(parseFloorPlan(existingRoom.floorPlan)).filter((spot) => {
const updated = newById.get(spot.id);
return !updated || spotDisplayName(updated) !== spotDisplayName(spot);
});
if (touchedSpots.length > 0) {
const booked = await prisma.courseBooking.findMany({
where: {
spotId: { in: touchedSpots.map((s) => s.id) },
paymentStatus: "paid",
course: { room: roomId, date: { gte: todayStart } },
},
select: { spotId: true },
distinct: ["spotId"],
});
if (booked.length > 0) {
const bookedIds = new Set(booked.map((b) => b.spotId));
const names = touchedSpots.filter((s) => bookedIds.has(s.id)).map(spotDisplayName);
return {
success: false,
error:
`${joinNames(names)} ${names.length === 1 ? "ist" : "sind"} noch für kommende Kurse gebucht. ` +
`Verschieben geht jederzeit, entfernen oder umnummerieren erst nach diesen Kursen.`,
};
}
}
// Schritt 10: Raum in der Datenbank aktualisieren
await prisma.room.update({ await prisma.room.update({
where: { id: roomId }, where: { id: roomId },
data: { data: {
name: validatedData.name, name: validatedData.name,
floorPlan: floorPlanColumn(newPlan),
}, },
}); });
// Schritt 9: Next.js Cache für /rooms Seite invalidieren // Schritt 11: Kurse mit Platzwahl in diesem Raum dürfen nicht mehr
// Teilnehmer erwarten, als es Plätze gibt - sonst würde die Zahlung des
// letzten Kunden im Webhook mangels Platz erstattet. Ohne Geräte entfällt
// die Platzwahl ganz, dann bleibt die Teilnehmerzahl wie sie ist.
const spotCount = bookableSpots(newPlan).length;
let cappedCount = 0;
if (spotCount > 0) {
const toCap = await prisma.course.findMany({
where: {
room: roomId,
createdBy: managerId,
spotBooking: true,
maxParticipants: { gt: spotCount },
OR: [{ date: { gte: todayStart } }, { isStandingOrder: true }],
},
select: { id: true, businessId: true, isStandingOrder: true },
});
if (toCap.length > 0) {
await prisma.course.updateMany({
where: { id: { in: toCap.map((c) => c.id) } },
data: { maxParticipants: spotCount },
});
cappedCount = toCap.filter((c) => !c.isStandingOrder).length;
revalidatePath("/courses");
refreshPublicCourses(...toCap.map((c) => c.businessId));
}
}
// Schritt 12: Next.js Cache für /rooms Seite invalidieren
revalidatePath("/rooms"); revalidatePath("/rooms");
revalidatePath(`/rooms/edit/${roomId}`); revalidatePath(`/rooms/edit/${roomId}`);
return { return {
success: true, success: true,
message: "Raum erfolgreich aktualisiert", message:
cappedCount > 0
? `Raum gespeichert. ${cappedCount} kommende ${cappedCount === 1 ? "Kurs hat" : "Kurse haben"} jetzt höchstens ${spotCount} Plätze.`
: "Raum erfolgreich aktualisiert",
}; };
} catch (error) { } catch (error) {
console.error("Error updating room:", error); console.error("Error updating room:", error);

View file

@ -0,0 +1,184 @@
import { z } from "zod";
/**
* Grundriss eines Raums (Room.floorPlan).
*
* Koordinaten in Metern, Ursprung oben links. x/y ist der MITTELPUNKT eines
* Geräts - so bleibt die Position beim Drehen gleich. Die Maße im Katalog gelten
* für rotation 0 (w entlang x, h entlang y); bei 90/270 tauschen sie.
*
* Buchbare Geräte (Reformer, Matte, ...) sind die Plätze, die Kunden bei Kursen
* mit Platzwahl auswählen. Ihre id ist stabil und landet als CourseBooking.spotId
* an der Buchung - deshalb darf der Editor ids nie neu vergeben, nur neue erzeugen.
*/
export const EQUIPMENT = {
reformer: { label: "Reformer", w: 0.7, h: 2.4, bookable: true },
tower: { label: "Tower", w: 0.75, h: 2.4, bookable: true },
cadillac: { label: "Cadillac", w: 0.8, h: 2.2, bookable: true },
chair: { label: "Chair", w: 0.65, h: 0.75, bookable: true },
barrel: { label: "Barrel", w: 0.75, h: 1.2, bookable: true },
mat: { label: "Matte", w: 0.65, h: 1.85, bookable: true },
trainer: { label: "Trainer", w: 0.8, h: 0.8, bookable: false },
door: { label: "Tür", w: 1.0, h: 0.2, bookable: false },
mirror: { label: "Spiegel", w: 2.0, h: 0.12, bookable: false },
} as const;
export type EquipmentType = keyof typeof EQUIPMENT;
export const EQUIPMENT_TYPES = Object.keys(EQUIPMENT) as EquipmentType[];
export const ROTATIONS = [0, 90, 180, 270] as const;
export type Rotation = (typeof ROTATIONS)[number];
export type FloorPlanItem = {
id: string;
type: EquipmentType;
x: number;
y: number;
rotation: Rotation;
/** Nummer des Platzes innerhalb seines Gerätetyps ("3" → "Reformer 3"). Leer bei Deko. */
label: string;
};
export type FloorPlan = {
width: number;
depth: number;
items: FloorPlanItem[];
};
export const ROOM_SIZE_MIN = 2;
export const ROOM_SIZE_MAX = 40;
export const DEFAULT_ROOM_SIZE = { width: 8, depth: 6 };
const MAX_ITEMS = 120;
/** Positionsraster im Editor: 10 cm. */
export const SNAP_STEP = 0.1;
const floorPlanItemSchema = z.object({
id: z.string().min(1).max(64),
type: z.enum(EQUIPMENT_TYPES as [EquipmentType, ...EquipmentType[]]),
x: z.number().finite(),
y: z.number().finite(),
rotation: z.union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)]),
label: z.string().trim().max(12, "Platznummer darf höchstens 12 Zeichen haben"),
});
export const floorPlanSchema = z
.object({
width: z.number().min(ROOM_SIZE_MIN).max(ROOM_SIZE_MAX),
depth: z.number().min(ROOM_SIZE_MIN).max(ROOM_SIZE_MAX),
items: z.array(floorPlanItemSchema).max(MAX_ITEMS, `Höchstens ${MAX_ITEMS} Geräte pro Raum`),
})
.refine((plan) => new Set(plan.items.map((i) => i.id)).size === plan.items.length, {
message: "Doppelte Geräte-IDs im Grundriss",
})
.refine(
(plan) => {
const seen = new Set<string>();
for (const item of plan.items) {
if (!EQUIPMENT[item.type].bookable) continue;
if (!item.label) return false;
const key = `${item.type}:${item.label.toLowerCase()}`;
if (seen.has(key)) return false;
seen.add(key);
}
return true;
},
{ message: "Jeder Platz braucht eine eigene Nummer pro Gerätetyp" }
);
/**
* Liest Room.floorPlan aus der Datenbank. Liefert null für Räume ohne
* Grundriss und für alles, was nicht dem Format entspricht - ein kaputter
* Grundriss darf eine Buchung nie blockieren, sie läuft dann ohne Platzwahl.
*/
export function parseFloorPlan(value: unknown): FloorPlan | null {
if (!value) return null;
const parsed = floorPlanSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
export function isBookable(item: Pick<FloorPlanItem, "type">): boolean {
return EQUIPMENT[item.type].bookable;
}
/** Buchbare Plätze in fester Reihenfolge: Katalog-Reihenfolge, dann Nummer. */
export function bookableSpots(plan: FloorPlan | null): FloorPlanItem[] {
if (!plan) return [];
return plan.items.filter(isBookable).sort((a, b) => {
const byType = EQUIPMENT_TYPES.indexOf(a.type) - EQUIPMENT_TYPES.indexOf(b.type);
if (byType !== 0) return byType;
return a.label.localeCompare(b.label, "de", { numeric: true });
});
}
/** "Reformer 3" - so heißt der Platz in Dialog, E-Mail und Buchungsübersicht. */
export function spotDisplayName(item: Pick<FloorPlanItem, "type" | "label">): string {
const name = EQUIPMENT[item.type].label;
return item.label ? `${name} ${item.label}` : name;
}
/** Ausdehnung auf dem Boden, abhängig von der Drehung. */
export function itemFootprint(item: Pick<FloorPlanItem, "type" | "rotation">) {
const { w, h } = EQUIPMENT[item.type];
return item.rotation % 180 === 0 ? { w, h } : { w: h, h: w };
}
/** Kleinste freie Nummer für einen Gerätetyp - "1", "2", ... */
export function nextLabel(items: FloorPlanItem[], type: EquipmentType): string {
if (!EQUIPMENT[type].bookable) return "";
const used = new Set(items.filter((i) => i.type === type).map((i) => i.label));
let n = 1;
while (used.has(String(n))) n++;
return String(n);
}
export function snap(value: number): number {
return Math.round(value / SNAP_STEP) * SNAP_STEP;
}
/** Hält ein Gerät vollständig innerhalb des Raums und rastet es ein. */
export function placeInRoom(
item: Pick<FloorPlanItem, "type" | "rotation">,
pos: { x: number; y: number },
room: Pick<FloorPlan, "width" | "depth">
): { x: number; y: number } {
const { w, h } = itemFootprint(item);
const clamp = (v: number, size: number, extent: number) =>
Math.min(Math.max(v, extent / 2), Math.max(extent / 2, size - extent / 2));
// Auf zwei Nachkommastellen runden, damit 0.1-Schritte nicht als 0.30000000000000004 landen.
const round = (v: number) => Math.round(v * 100) / 100;
return {
x: round(clamp(snap(pos.x), room.width, w)),
y: round(clamp(snap(pos.y), room.depth, h)),
};
}
/**
* Erste Stelle von oben links, an der ein neues Gerät nichts überlappt - damit
* "Hinzufügen" per Klick die Geräte nebeneinander reiht statt sie zu stapeln.
* Ist der Raum voll, landet es in der Mitte.
*/
export function findFreePosition(
plan: FloorPlan,
item: Pick<FloorPlanItem, "type" | "rotation">
): { x: number; y: number } {
const { w, h } = itemFootprint(item);
const gap = 0.3;
const overlaps = (x: number, y: number) =>
plan.items.some((other) => {
const o = itemFootprint(other);
return (
Math.abs(other.x - x) < (o.w + w) / 2 + gap / 2 &&
Math.abs(other.y - y) < (o.h + h) / 2 + gap / 2
);
});
for (let y = h / 2 + gap; y + h / 2 <= plan.depth; y += h + gap) {
for (let x = w / 2 + gap; x + w / 2 <= plan.width; x += w + gap) {
const candidate = placeInRoom(item, { x, y }, plan);
if (!overlaps(candidate.x, candidate.y)) return candidate;
}
}
return placeInRoom(item, { x: plan.width / 2, y: plan.depth / 2 }, plan);
}

View file

@ -1,7 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { floorPlanSchema } from "../lib/floor-plan";
export const roomSchema = z.object({ export const roomSchema = z.object({
name: z.string().min(1, "Name ist erforderlich"), name: z.string().min(1, "Name ist erforderlich"),
// null = Raum ohne Grundriss (z.B. Matten-Raum ohne feste Plätze)
floorPlan: floorPlanSchema.nullable().optional(),
}); });
export type RoomFormData = z.infer<typeof roomSchema>; export type RoomFormData = z.infer<typeof roomSchema>;

View file

@ -2,6 +2,8 @@ export type Room = {
id: string; id: string;
name: string; name: string;
createdBy: string; createdBy: string;
/** Rohwert aus der DB - über parseFloorPlan() lesen. */
floorPlan?: unknown;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}; };

View file

@ -5,6 +5,7 @@ import { Pen, Trash2 } from "lucide-react";
*/ */
interface RoomsListItemProps { interface RoomsListItemProps {
roomName: string; // Name des Raumes roomName: string; // Name des Raumes
spotCount: number; // Buchbare Plätze laut Grundriss, 0 = kein Grundriss
onEdit?: () => void; // Callback-Funktion beim Klick auf Bearbeiten-Button onEdit?: () => void; // Callback-Funktion beim Klick auf Bearbeiten-Button
onDelete?: () => void; // Callback-Funktion beim Klick auf Löschen-Button onDelete?: () => void; // Callback-Funktion beim Klick auf Löschen-Button
showDeleteIcon?: boolean; // Ob das Löschen-Icon angezeigt werden soll showDeleteIcon?: boolean; // Ob das Löschen-Icon angezeigt werden soll
@ -12,6 +13,7 @@ interface RoomsListItemProps {
const RoomsListItem: React.FC<RoomsListItemProps> = ({ const RoomsListItem: React.FC<RoomsListItemProps> = ({
roomName, roomName,
spotCount,
onEdit, onEdit,
onDelete, onDelete,
showDeleteIcon, showDeleteIcon,
@ -21,6 +23,11 @@ const RoomsListItem: React.FC<RoomsListItemProps> = ({
<div className="w-full flex items-center justify-between"> <div className="w-full flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
<h2 className="text-xl text-gray-900">{roomName}</h2> <h2 className="text-xl text-gray-900">{roomName}</h2>
<p className="text-sm text-gray-500 mt-0.5">
{spotCount === 0
? "Ohne Grundriss"
: `${spotCount} ${spotCount === 1 ? "Platz" : "Plätze"} im Grundriss`}
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{showDeleteIcon && ( {showDeleteIcon && (

View file

@ -0,0 +1,496 @@
"use client";
import { useRef, useState, type KeyboardEvent } from "react";
import {
DndContext,
DragOverlay,
MouseSensor,
TouchSensor,
useDraggable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
type Modifier,
} from "@dnd-kit/core";
import { CSS, getEventCoordinates } from "@dnd-kit/utilities";
import { Copy, RotateCw, Trash2 } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { cn } from "@/src/lib/utils";
import {
EQUIPMENT,
EQUIPMENT_TYPES,
ROOM_SIZE_MAX,
ROOM_SIZE_MIN,
SNAP_STEP,
findFreePosition,
isBookable,
itemFootprint,
nextLabel,
placeInRoom,
type EquipmentType,
type FloorPlan,
type FloorPlanItem,
type Rotation,
} from "../../../lib/floor-plan";
import { EquipmentGraphic, EquipmentShape, FloorPlanStage, itemBoxStyle } from "./FloorPlanStage";
type DragData = { kind: "palette"; type: EquipmentType } | { kind: "item" };
const BOOKABLE_TYPES = EQUIPMENT_TYPES.filter((t) => EQUIPMENT[t].bookable);
const ROOM_TYPES = EQUIPMENT_TYPES.filter((t) => !EQUIPMENT[t].bookable);
/** Legt die Mitte des gezogenen Geräts unter den Mauszeiger (wie @dnd-kit/modifiers). */
const snapCenterToCursor: Modifier = ({ activatorEvent, draggingNodeRect, transform }) => {
if (!draggingNodeRect || !activatorEvent) return transform;
const coords = getEventCoordinates(activatorEvent);
if (!coords) return transform;
return {
...transform,
x: transform.x + coords.x - draggingNodeRect.left - draggingNodeRect.width / 2,
y: transform.y + coords.y - draggingNodeRect.top - draggingNodeRect.height / 2,
};
};
interface FloorPlanEditorProps {
value: FloorPlan;
onChange: (plan: FloorPlan) => void;
}
/**
* Grundriss-Editor: Geräte aus der Leiste in den Raum ziehen (oder antippen),
* im Raum verschieben, anklicken zum Drehen/Nummerieren/Entfernen.
* Tastatur: Pfeile verschieben (mit Shift in 50-cm-Schritten), R dreht,
* Entf entfernt, Esc hebt die Auswahl auf.
*/
export default function FloorPlanEditor({ value: plan, onChange }: FloorPlanEditorProps) {
const stageRef = useRef<HTMLDivElement>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [paletteDrag, setPaletteDrag] = useState<{
type: EquipmentType;
pxPerMeter: number;
} | null>(null);
const sensors = useSensors(
// Kleiner Schwellwert, damit ein Klick ein Klick bleibt (auswählen/hinzufügen).
useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
// Am Handy erst nach kurzem Halten ziehen - sonst ließe sich die
// Geräteleiste nicht mehr seitlich wischen.
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 6 } })
);
const selected = plan.items.find((i) => i.id === selectedId) ?? null;
const spotCount = plan.items.filter(isBookable).length;
const setItems = (items: FloorPlanItem[]) => onChange({ ...plan, items });
const updateItem = (id: string, patch: Partial<FloorPlanItem>) =>
setItems(plan.items.map((i) => (i.id === id ? { ...i, ...patch } : i)));
const addItem = (type: EquipmentType, at?: { x: number; y: number }) => {
const base = { type, rotation: 0 as Rotation };
const pos = at ? placeInRoom(base, at, plan) : findFreePosition(plan, base);
const item: FloorPlanItem = {
id: crypto.randomUUID(),
...base,
...pos,
label: nextLabel(plan.items, type),
};
setItems([...plan.items, item]);
setSelectedId(item.id);
};
const moveBy = (item: FloorPlanItem, dx: number, dy: number) =>
updateItem(item.id, placeInRoom(item, { x: item.x + dx, y: item.y + dy }, plan));
const rotate = (item: FloorPlanItem) => {
const rotation = ((item.rotation + 90) % 360) as Rotation;
// Nach dem Drehen kann ein Gerät über die Wand ragen - wieder hineinschieben.
updateItem(item.id, { rotation, ...placeInRoom({ ...item, rotation }, item, plan) });
};
const remove = (item: FloorPlanItem) => {
setItems(plan.items.filter((i) => i.id !== item.id));
setSelectedId(null);
};
// Ein Duplikat landet direkt rechts daneben - so entsteht eine Reihe Reformer
// mit ein paar Klicks. Ist rechts kein Platz mehr, sucht es sich eine Lücke.
const duplicate = (item: FloorPlanItem) => {
const { w } = itemFootprint(item);
const besideX = item.x + w + 0.3;
const fits = besideX + w / 2 <= plan.width;
const copy = { type: item.type, rotation: item.rotation };
const pos = fits
? placeInRoom(copy, { x: besideX, y: item.y }, plan)
: findFreePosition(plan, copy);
const newItem: FloorPlanItem = {
id: crypto.randomUUID(),
...copy,
...pos,
label: nextLabel(plan.items, item.type),
};
setItems([...plan.items, newItem]);
setSelectedId(newItem.id);
};
const setRoomSize = (patch: { width?: number; depth?: number }) => {
const next = { ...plan, ...patch };
onChange({
...next,
items: next.items.map((i) => ({ ...i, ...placeInRoom(i, i, next) })),
});
};
const handleDragStart = ({ active }: DragStartEvent) => {
const data = active.data.current as DragData | undefined;
if (data?.kind === "palette") {
const width = stageRef.current?.getBoundingClientRect().width ?? 0;
setPaletteDrag({ type: data.type, pxPerMeter: width / plan.width });
}
};
const handleDragEnd = ({ active, delta, activatorEvent }: DragEndEvent) => {
setPaletteDrag(null);
const data = active.data.current as DragData | undefined;
const rect = stageRef.current?.getBoundingClientRect();
if (!data || !rect) return;
const pxPerMeter = rect.width / plan.width;
if (data.kind === "item") {
const item = plan.items.find((i) => i.id === active.id);
if (item) moveBy(item, delta.x / pxPerMeter, delta.y / pxPerMeter);
setSelectedId(String(active.id));
return;
}
// Aus der Leiste: abgelegt wird, wo der Zeiger losgelassen wurde.
const start = getEventCoordinates(activatorEvent);
if (!start) return;
const px = start.x + delta.x;
const py = start.y + delta.y;
if (px < rect.left || px > rect.right || py < rect.top || py > rect.bottom) return;
addItem(data.type, { x: (px - rect.left) / pxPerMeter, y: (py - rect.top) / pxPerMeter });
};
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!selected) return;
const step = e.shiftKey ? 0.5 : SNAP_STEP;
const actions: Record<string, () => void> = {
ArrowLeft: () => moveBy(selected, -step, 0),
ArrowRight: () => moveBy(selected, step, 0),
ArrowUp: () => moveBy(selected, 0, -step),
ArrowDown: () => moveBy(selected, 0, step),
r: () => rotate(selected),
R: () => rotate(selected),
Delete: () => remove(selected),
Backspace: () => remove(selected),
Escape: () => setSelectedId(null),
};
const action = actions[e.key];
if (action) {
e.preventDefault();
action();
}
};
const labelTaken =
selected !== null &&
isBookable(selected) &&
plan.items.some(
(i) =>
i.id !== selected.id &&
i.type === selected.type &&
i.label.toLowerCase() === selected.label.trim().toLowerCase()
);
return (
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="flex flex-col gap-4">
{/* Raumgröße + Zähler */}
<div className="flex flex-wrap items-end gap-3">
<RoomSizeInput
label="Breite"
value={plan.width}
onChange={(width) => setRoomSize({ width })}
/>
<span className="pb-2.5 text-gray-400">×</span>
<RoomSizeInput
label="Tiefe"
value={plan.depth}
onChange={(depth) => setRoomSize({ depth })}
/>
<span className="ml-auto pb-2 text-sm text-gray-600">
<span className="font-semibold text-gray-900">{spotCount}</span>{" "}
{spotCount === 1 ? "buchbarer Platz" : "buchbare Plätze"}
</span>
</div>
<div className="flex flex-col gap-4 md:flex-row">
{/* Geräteleiste: am Handy eine wischbare Reihe, ab md eine Spalte */}
<div className="flex shrink-0 gap-2 overflow-x-auto pb-1 md:w-40 md:flex-col md:overflow-visible md:pb-0">
<PaletteHeading>Buchbar</PaletteHeading>
{BOOKABLE_TYPES.map((type) => (
<PaletteItem key={type} type={type} onAdd={() => addItem(type)} />
))}
<PaletteHeading className="md:mt-2">Raum</PaletteHeading>
{ROOM_TYPES.map((type) => (
<PaletteItem key={type} type={type} onAdd={() => addItem(type)} />
))}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3">
{/* Auswahl-Leiste */}
<div className="flex min-h-11 flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white/70 px-3 py-1.5">
{selected ? (
<>
<span className="text-sm font-medium text-gray-900">
{EQUIPMENT[selected.type].label}
</span>
{isBookable(selected) && (
<label className="flex items-center gap-1.5 text-sm text-gray-600">
Nr.
<input
value={selected.label}
maxLength={12}
onChange={(e) => updateItem(selected.id, { label: e.target.value })}
aria-invalid={labelTaken || !selected.label.trim()}
className={cn(
"h-8 w-16 rounded-md border px-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-[#c9a84c]/40",
labelTaken || !selected.label.trim()
? "border-red-400"
: "border-gray-300"
)}
/>
</label>
)}
{labelTaken && <span className="text-xs text-red-600">Nummer gibt es schon</span>}
<div className="ml-auto flex gap-1.5">
<Button size="sm" variant="outline" onClick={() => rotate(selected)}>
<RotateCw className="size-4" /> Drehen
</Button>
<Button size="sm" variant="outline" onClick={() => duplicate(selected)}>
<Copy className="size-4" /> Duplizieren
</Button>
<Button
size="sm"
variant="outline"
onClick={() => remove(selected)}
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label="Gerät entfernen"
>
<Trash2 className="size-4" />
</Button>
</div>
</>
) : (
<span className="text-sm text-gray-500">
Gerät anklicken, um es zu drehen, zu nummerieren oder zu entfernen.
</span>
)}
</div>
{/* Raum */}
<div onKeyDown={handleKeyDown}>
<FloorPlanStage
plan={plan}
stageRef={stageRef}
maxHeight="65vh"
onClick={() => setSelectedId(null)}
>
{plan.items.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center p-4 text-center text-sm text-gray-400">
Geräte hierher ziehen oder in der Leiste antippen
</div>
)}
{plan.items.map((item) => (
<PlacedItem
key={item.id}
item={item}
plan={plan}
selected={item.id === selectedId}
onSelect={() => setSelectedId(item.id)}
/>
))}
</FloorPlanStage>
</div>
</div>
</div>
</div>
<DragOverlay modifiers={[snapCenterToCursor]} dropAnimation={null}>
{paletteDrag && (
<div
className="relative opacity-80"
style={{
width: EQUIPMENT[paletteDrag.type].w * paletteDrag.pxPerMeter,
height: EQUIPMENT[paletteDrag.type].h * paletteDrag.pxPerMeter,
}}
>
<EquipmentGraphic
item={{
type: paletteDrag.type,
rotation: 0,
label: nextLabel(plan.items, paletteDrag.type),
}}
tone="selected"
/>
</div>
)}
</DragOverlay>
</DndContext>
);
}
function PaletteHeading({ children, className }: { children: string; className?: string }) {
return (
<span
className={cn(
"hidden text-xs font-semibold uppercase tracking-wide text-gray-400 md:block",
className
)}
>
{children}
</span>
);
}
function PaletteItem({ type, onAdd }: { type: EquipmentType; onAdd: () => void }) {
const { attributes, listeners, setNodeRef } = useDraggable({
id: `palette:${type}`,
data: { kind: "palette", type } satisfies DragData,
});
const { w, h, label } = EQUIPMENT[type];
// Vorschau in einem 28-px-Kasten, maßstabsgetreu zueinander.
const scale = 28 / Math.max(w, h);
return (
<button
ref={setNodeRef}
type="button"
{...attributes}
{...listeners}
onClick={onAdd}
aria-label={`${label} hinzufügen`}
className="flex shrink-0 cursor-grab touch-manipulation items-center gap-2.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-sm text-gray-700 transition-colors hover:border-[#c9a84c]/60 hover:bg-[#c9a84c]/5 active:cursor-grabbing"
>
<span className="flex size-7 shrink-0 items-center justify-center">
<span
className="relative"
style={
{
width: w * scale,
height: h * scale,
"--fp-fill": "#ffffff",
"--fp-stroke": "#64748b",
"--fp-detail": "#cbd5e1",
} as React.CSSProperties
}
>
<EquipmentShape type={type} rotation={0} />
</span>
</span>
{label}
</button>
);
}
function PlacedItem({
item,
plan,
selected,
onSelect,
}: {
item: FloorPlanItem;
plan: FloorPlan;
selected: boolean;
onSelect: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: item.id,
data: { kind: "item" } satisfies DragData,
});
const name = item.label
? `${EQUIPMENT[item.type].label} ${item.label}`
: EQUIPMENT[item.type].label;
return (
<button
ref={setNodeRef}
type="button"
{...attributes}
{...listeners}
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
onFocus={onSelect}
aria-label={name}
title={name}
className={cn(
"cursor-grab touch-none select-none rounded-sm outline-none active:cursor-grabbing",
"focus-visible:ring-2 focus-visible:ring-[#c9a84c] focus-visible:ring-offset-1"
)}
style={{
...itemBoxStyle(item, plan),
transform: CSS.Translate.toString(transform),
zIndex: isDragging ? 30 : selected ? 20 : undefined,
}}
>
<EquipmentGraphic item={item} tone={selected ? "selected" : "default"} />
</button>
);
}
function RoomSizeInput({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: number) => void;
}) {
// Eigener Textzustand: beim Tippen von "12" ist "1" kurz ungültig und darf
// die Geräte nicht schon an die Wand schieben.
const [text, setText] = useState(String(value).replace(".", ","));
const [lastValue, setLastValue] = useState(value);
if (value !== lastValue) {
setLastValue(value);
setText(String(value).replace(".", ","));
}
const parse = (raw: string) => {
const n = Number(raw.replace(",", "."));
return Number.isFinite(n) && n >= ROOM_SIZE_MIN && n <= ROOM_SIZE_MAX
? Math.round(n * 10) / 10
: null;
};
const invalid = parse(text) === null;
return (
<label className="flex flex-col gap-1 text-sm text-gray-600">
{label}
<span className="flex items-center gap-1.5">
<input
inputMode="decimal"
value={text}
onChange={(e) => {
setText(e.target.value);
const n = parse(e.target.value);
if (n !== null) {
setLastValue(n);
onChange(n);
}
}}
onBlur={() => setText(String(value).replace(".", ","))}
aria-invalid={invalid}
className={cn(
"h-10 w-20 rounded-lg border px-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-[#c9a84c]/40",
invalid ? "border-red-400" : "border-gray-300"
)}
/>
<span className="text-gray-500">m</span>
</span>
</label>
);
}

View file

@ -0,0 +1,267 @@
import type { CSSProperties, ReactNode, Ref } from "react";
import { cn } from "@/src/lib/utils";
import {
EQUIPMENT,
isBookable,
itemFootprint,
type EquipmentType,
type FloorPlan,
type FloorPlanItem,
type Rotation,
} from "../../../lib/floor-plan";
/**
* Gemeinsame Darstellung des Grundrisses für Editor (Raum bearbeiten) und
* Platzwahl (Buchungsdialog). Alles wird in Prozent des Raums positioniert -
* die Bühne hat das Seitenverhältnis des Raums, damit skaliert der Plan mit
* jeder Bildschirmbreite, ohne dass jemand Pixel umrechnen muss.
*/
export type EquipmentTone = "default" | "selected" | "free" | "taken" | "picked" | "decor";
type ToneColors = { fill: string; stroke: string; detail: string; text: string };
const TONES: Record<Exclude<EquipmentTone, "picked">, ToneColors> = {
default: { fill: "#ffffff", stroke: "#64748b", detail: "#cbd5e1", text: "#334155" },
selected: { fill: "#fdf8ec", stroke: "#c9a84c", detail: "#e7d6a3", text: "#7a5f17" },
free: { fill: "#ffffff", stroke: "#475569", detail: "#cbd5e1", text: "#1e293b" },
taken: { fill: "#e2e8f0", stroke: "#cbd5e1", detail: "#cbd5e1", text: "#94a3b8" },
decor: { fill: "#f8fafc", stroke: "#cbd5e1", detail: "#e2e8f0", text: "#94a3b8" },
};
function toneColors(tone: EquipmentTone, accent?: { color: string; text: string }): ToneColors {
if (tone === "picked") {
const color = accent?.color ?? "#c9a84c";
return {
fill: color,
stroke: color,
detail: "rgba(255,255,255,0.45)",
text: accent?.text ?? "#fff",
};
}
return TONES[tone];
}
/** Position und Größe eines Geräts in Prozent der Bühne. */
export function itemBoxStyle(
item: Pick<FloorPlanItem, "type" | "rotation" | "x" | "y">,
plan: Pick<FloorPlan, "width" | "depth">
): CSSProperties {
const { w, h } = itemFootprint(item);
return {
position: "absolute",
left: `${((item.x - w / 2) / plan.width) * 100}%`,
top: `${((item.y - h / 2) / plan.depth) * 100}%`,
width: `${(w / plan.width) * 100}%`,
height: `${(h / plan.depth) * 100}%`,
};
}
interface FloorPlanStageProps {
plan: Pick<FloorPlan, "width" | "depth">;
children?: ReactNode;
/** Begrenzt die Höhe - ein tiefer, schmaler Raum soll nicht den ganzen Bildschirm füllen. */
maxHeight?: string;
className?: string;
stageRef?: Ref<HTMLDivElement>;
onClick?: () => void;
}
export function FloorPlanStage({
plan,
children,
maxHeight = "60vh",
className,
stageRef,
onClick,
}: FloorPlanStageProps) {
const ratio = plan.width / plan.depth;
return (
<div
className="w-full"
style={{ maxWidth: `calc(${maxHeight} * ${ratio})`, marginInline: "auto" }}
>
<div
ref={stageRef}
onClick={onClick}
className={cn(
"relative w-full rounded-lg border-2 border-slate-300 bg-white [container-type:inline-size]",
className
)}
style={{
aspectRatio: `${plan.width} / ${plan.depth}`,
// Ein Rasterkästchen = 1 Meter
backgroundImage:
"linear-gradient(to right, rgb(148 163 184 / 0.18) 1px, transparent 1px), linear-gradient(to bottom, rgb(148 163 184 / 0.18) 1px, transparent 1px)",
backgroundSize: `${100 / plan.width}% ${100 / plan.depth}%`,
}}
>
{children}
</div>
</div>
);
}
/** Gerät samt Platznummer, füllt seinen (per itemBoxStyle positionierten) Container. */
export function EquipmentGraphic({
item,
tone,
accent,
}: {
item: Pick<FloorPlanItem, "type" | "rotation" | "label">;
tone: EquipmentTone;
accent?: { color: string; text: string };
}) {
const colors = toneColors(tone, accent);
return (
<span
className="pointer-events-none absolute inset-0"
style={
{
"--fp-fill": colors.fill,
"--fp-stroke": colors.stroke,
"--fp-detail": colors.detail,
} as CSSProperties
}
>
<EquipmentShape type={item.type} rotation={item.rotation} />
{isBookable(item) && item.label && (
<span className="absolute inset-0 flex items-center justify-center">
<span
className="rounded-full px-[0.35em] font-semibold leading-tight tabular-nums"
style={{
fontSize: "clamp(9px, 1.7cqw, 14px)",
color: colors.text,
backgroundColor: colors.fill,
}}
>
{item.label}
</span>
</span>
)}
</span>
);
}
/**
* Draufsicht eines Geräts. Gezeichnet in Zentimetern, ungedreht (w entlang x,
* h entlang y), dann um den Mittelpunkt gedreht. Die Linienstärke bleibt beim
* Skalieren gleich (non-scaling-stroke), damit Geräte auch klein lesbar sind.
*/
export function EquipmentShape({ type, rotation }: { type: EquipmentType; rotation: Rotation }) {
const W = EQUIPMENT[type].w * 100;
const H = EQUIPMENT[type].h * 100;
const turned = rotation % 180 !== 0;
const fw = turned ? H : W;
const fh = turned ? W : H;
return (
<svg
viewBox={`0 0 ${fw} ${fh}`}
preserveAspectRatio="none"
className="absolute inset-0 size-full overflow-visible"
aria-hidden
>
<g
transform={`translate(${fw / 2} ${fh / 2}) rotate(${rotation}) translate(${-W / 2} ${-H / 2})`}
fill="var(--fp-fill)"
stroke="var(--fp-stroke)"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
>
{DRAWINGS[type](W, H)}
</g>
</svg>
);
}
const line = { vectorEffect: "non-scaling-stroke" as const };
const detail = { ...line, stroke: "var(--fp-detail)", fill: "var(--fp-detail)" };
const detailLine = { ...line, stroke: "var(--fp-detail)", fill: "none" };
function reformer(W: number, H: number) {
return (
<>
<rect x={0} y={0} width={W} height={H} rx={8} {...line} />
{/* Fußstange */}
<line x1={8} y1={22} x2={W - 8} y2={22} strokeWidth={2} {...detailLine} />
{/* Schlitten */}
<rect x={10} y={H * 0.4} width={W - 20} height={H * 0.32} rx={6} {...detail} />
</>
);
}
const DRAWINGS: Record<EquipmentType, (W: number, H: number) => React.ReactNode> = {
reformer,
tower: (W, H) => (
<>
{reformer(W, H)}
{/* Turm am Kopfende */}
<rect x={-4} y={H - 18} width={W + 8} height={18} rx={3} {...line} />
</>
),
cadillac: (W, H) => (
<>
<rect x={0} y={0} width={W} height={H} rx={4} {...line} />
<rect x={9} y={9} width={W - 18} height={H - 18} rx={4} {...detail} />
{[
[5, 5],
[W - 5, 5],
[5, H - 5],
[W - 5, H - 5],
].map(([cx, cy]) => (
<circle key={`${cx}-${cy}`} cx={cx} cy={cy} r={6} {...line} />
))}
</>
),
chair: (W, H) => (
<>
<rect x={0} y={0} width={W} height={H} rx={6} {...line} />
<rect x={8} y={8} width={W - 16} height={H * 0.5} rx={4} {...detail} />
{/* Pedal */}
<line x1={10} y1={H - 14} x2={W - 10} y2={H - 14} strokeWidth={2} {...detailLine} />
</>
),
barrel: (W, H) => (
<>
<rect x={0} y={0} width={W} height={H} rx={6} {...line} />
{/* Sprossen */}
{[0.12, 0.24, 0.36].map((f) => (
<line key={f} x1={8} y1={H * f} x2={W - 8} y2={H * f} {...detailLine} />
))}
{/* Fass */}
<rect x={6} y={H * 0.5} width={W - 12} height={H * 0.44} rx={(W - 12) / 2} {...detail} />
</>
),
mat: (W, H) => (
<>
<rect x={0} y={0} width={W} height={H} rx={10} {...line} />
{[0.25, 0.5, 0.75].map((f) => (
<line key={f} x1={8} y1={H * f} x2={W - 8} y2={H * f} {...detailLine} />
))}
</>
),
trainer: (W, H) => (
<>
<circle cx={W / 2} cy={H / 2} r={W / 2 - 2} strokeDasharray="6 5" {...line} />
<circle cx={W / 2} cy={H / 2 - 10} r={10} {...detail} />
<path
d={`M ${W / 2 - 20} ${H / 2 + 24} Q ${W / 2} ${H / 2 - 2} ${W / 2 + 20} ${H / 2 + 24} Z`}
{...detail}
/>
</>
),
door: (W, H) => (
<>
<rect x={0} y={0} width={W} height={H} {...line} />
{/* Türblatt und Schwenkbereich - ragen bewusst in den Raum hinein */}
<path
d={`M 0 ${H} L 0 ${H + W}`}
strokeWidth={1.5}
{...detailLine}
stroke="var(--fp-stroke)"
/>
<path d={`M ${W} ${H} A ${W} ${W} 0 0 1 0 ${H + W}`} strokeDasharray="5 5" {...detailLine} />
</>
),
mirror: (W, H) => <rect x={0} y={0} width={W} height={H} fill="#e0f2fe" {...line} />,
};

View file

@ -0,0 +1,117 @@
"use client";
import { cn } from "@/src/lib/utils";
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
import { isBookable, spotDisplayName, type FloorPlan } from "../../../lib/floor-plan";
import {
EquipmentGraphic,
FloorPlanStage,
itemBoxStyle,
type EquipmentTone,
} from "./FloorPlanStage";
const DEFAULT_ACCENT = "#c9a84c";
interface SpotPickerProps {
floorPlan: FloorPlan;
/** Bezahlt gebucht oder gerade von jemand anderem im Checkout. */
takenSpotIds: string[];
selectedSpotId: string | null;
/** Platz der eigenen Buchung - wird hervorgehoben, ist aber nicht wählbar. */
mySpotId?: string | null;
/** Fehlt = nur ansehen. */
onSelect?: (spotId: string) => void;
/** Akzentfarbe des Studios für den gewählten Platz. */
accentColor?: string;
}
/**
* Grundriss im Buchungsdialog: freie Geräte antippen, belegte sind grau.
* Trainer, Tür und Spiegel helfen nur bei der Orientierung und sind nicht wählbar.
*/
export function SpotPicker({
floorPlan,
takenSpotIds,
selectedSpotId,
mySpotId = null,
onSelect,
accentColor = DEFAULT_ACCENT,
}: SpotPickerProps) {
const taken = new Set(takenSpotIds);
const accent = { color: accentColor, text: getContrastTextColor(accentColor) };
return (
<div className="flex flex-col gap-2.5">
<FloorPlanStage plan={floorPlan} maxHeight="45vh">
{floorPlan.items.map((item) => {
if (!isBookable(item)) {
return (
<div key={item.id} style={itemBoxStyle(item, floorPlan)} aria-hidden>
<EquipmentGraphic item={item} tone="decor" />
</div>
);
}
const isMine = item.id === mySpotId;
const isTaken = !isMine && taken.has(item.id);
const isSelected = item.id === selectedSpotId;
const tone: EquipmentTone = isMine || isSelected ? "picked" : isTaken ? "taken" : "free";
const name = spotDisplayName(item);
const state = isMine
? "Ihr Platz"
: isTaken
? "belegt"
: isSelected
? "ausgewählt"
: "frei";
return (
<button
key={item.id}
type="button"
disabled={!onSelect || isTaken || isMine}
onClick={() => onSelect?.(item.id)}
aria-pressed={onSelect ? isSelected : undefined}
aria-label={`${name}, ${state}`}
title={`${name} – ${state}`}
className={cn(
"rounded-sm outline-none transition-transform",
"focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-1",
"enabled:cursor-pointer enabled:hover:scale-[1.04] disabled:cursor-default",
isSelected && "z-10"
)}
style={itemBoxStyle(item, floorPlan)}
>
<EquipmentGraphic item={item} tone={tone} accent={accent} />
</button>
);
})}
</FloorPlanStage>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500">
<LegendSwatch className="border-slate-500 bg-white">Frei</LegendSwatch>
<LegendSwatch className="border-slate-300 bg-slate-200">Belegt</LegendSwatch>
<LegendSwatch style={{ backgroundColor: accent.color, borderColor: accent.color }}>
{mySpotId ? "Ihr Platz" : "Ihre Wahl"}
</LegendSwatch>
</div>
</div>
);
}
function LegendSwatch({
children,
className,
style,
}: {
children: string;
className?: string;
style?: React.CSSProperties;
}) {
return (
<span className="flex items-center gap-1.5">
<span className={cn("size-3 rounded-sm border", className)} style={style} />
{children}
</span>
);
}

View file

@ -6,9 +6,16 @@ import { Button } from "@/src/components/ui/button";
import { createRoom, updateRoom } from "../../actions/room-actions"; import { createRoom, updateRoom } from "../../actions/room-actions";
import { toast } from "sonner"; import { toast } from "sonner";
import { Room } from "../../types/room.types"; import { Room } from "../../types/room.types";
import { Trash2 } from "lucide-react"; import { LayoutGrid, Trash2 } from "lucide-react";
import DeleteDialog from "@/src/components/layout/DeleteDialog"; import DeleteDialog from "@/src/components/layout/DeleteDialog";
import { useDeleteRoom } from "../../hooks/useDeleteRoom"; import { useDeleteRoom } from "../../hooks/useDeleteRoom";
import FloorPlanEditor from "../components/floor-plan/FloorPlanEditor";
import {
DEFAULT_ROOM_SIZE,
floorPlanSchema,
parseFloorPlan,
type FloorPlan,
} from "../../lib/floor-plan";
interface RoomsCreateViewProps { interface RoomsCreateViewProps {
mode?: "create" | "edit"; mode?: "create" | "edit";
@ -19,6 +26,9 @@ interface RoomsCreateViewProps {
const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateViewProps = {}) => { const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateViewProps = {}) => {
const router = useRouter(); const router = useRouter();
const [roomName, setRoomName] = useState(mode === "edit" ? initialData?.name || "" : ""); const [roomName, setRoomName] = useState(mode === "edit" ? initialData?.name || "" : "");
const [floorPlan, setFloorPlan] = useState<FloorPlan>(
() => parseFloorPlan(initialData?.floorPlan) ?? { ...DEFAULT_ROOM_SIZE, items: [] }
);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -39,13 +49,19 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
if (!roomName.trim()) newErrors.name = "Raumname ist erforderlich"; if (!roomName.trim()) newErrors.name = "Raumname ist erforderlich";
if (floorPlan.items.length > 0) {
const plan = floorPlanSchema.safeParse(floorPlan);
if (!plan.success)
newErrors.floorPlan = plan.error.issues[0]?.message ?? "Ungültiger Grundriss";
}
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!validateForm()) { if (!validateForm()) {
toast.error("Bitte füllen Sie alle erforderlichen Felder aus"); toast.error("Bitte prüfen Sie Ihre Eingaben");
return; return;
} }
@ -54,6 +70,7 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
try { try {
const roomData = { const roomData = {
name: roomName, name: roomName,
floorPlan: floorPlan.items.length > 0 ? floorPlan : null,
}; };
if (mode === "edit" && !roomId) { if (mode === "edit" && !roomId) {
@ -95,7 +112,7 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
</h1> </h1>
<p className="text-xl mt-2"> <p className="text-xl mt-2">
{mode === "edit" {mode === "edit"
? "Ändern Sie den Raumnamen und speichern Sie Ihre Änderungen" ? "Ändern Sie Name und Grundriss und speichern Sie Ihre Änderungen"
: "Alle Räume können nach dem Speichern angepasst oder gelöscht werden"} : "Alle Räume können nach dem Speichern angepasst oder gelöscht werden"}
</p> </p>
</div> </div>
@ -105,8 +122,8 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
</Button> </Button>
)} )}
</div> </div>
<div className="mt-6 flex flex-col gap-4 max-w-xl"> <div className="mt-6 flex flex-col gap-5 max-w-5xl">
<div> <div className="max-w-xl">
<InputComponent <InputComponent
isLabel isLabel
label="Name" label="Name"
@ -117,7 +134,25 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
/> />
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>} {errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
</div> </div>
<div className="flex gap-2">
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 sm:p-6">
<div className="flex items-start gap-2 mb-5">
<div className="flex items-center justify-center size-8 shrink-0 rounded-lg bg-[#c9a84c]/10 text-[#c9a84c]">
<LayoutGrid className="size-4" />
</div>
<div>
<h2 className="text-base font-semibold text-gray-900">Grundriss (optional)</h2>
<p className="text-sm text-gray-500 mt-0.5">
Stellen Sie Reformer, Matten und andere Geräte so auf, wie sie im Raum stehen. Bei
Kursen mit Platzwahl suchen sich Kunden ihr Gerät dann selbst aus.
</p>
</div>
</div>
<FloorPlanEditor value={floorPlan} onChange={setFloorPlan} />
{errors.floorPlan && <p className="text-red-500 text-sm mt-2">{errors.floorPlan}</p>}
</div>
<div className="flex gap-2 pb-4">
<Button onClick={handleSubmit} disabled={isSubmitting}> <Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting {isSubmitting
? "Wird gespeichert..." ? "Wird gespeichert..."

View file

@ -4,14 +4,8 @@ import { useRouter } from "next/navigation";
import RoomsListItem from "../components/RoomsListItem"; import RoomsListItem from "../components/RoomsListItem";
import DeleteDialog from "@/src/components/layout/DeleteDialog"; import DeleteDialog from "@/src/components/layout/DeleteDialog";
import { useDeleteRoom } from "../../hooks/useDeleteRoom"; import { useDeleteRoom } from "../../hooks/useDeleteRoom";
import { bookableSpots, parseFloorPlan } from "../../lib/floor-plan";
type Room = { import type { Room } from "../../types/room.types";
id: string;
name: string;
createdBy: string;
createdAt: Date;
updatedAt: Date;
};
interface RoomsListViewProps { interface RoomsListViewProps {
rooms: Room[]; rooms: Room[];
@ -51,6 +45,7 @@ const RoomsListView = ({ rooms, deleteMode, canEdit, canDelete }: RoomsListViewP
<RoomsListItem <RoomsListItem
key={room.id} key={room.id}
roomName={room.name} roomName={room.name}
spotCount={bookableSpots(parseFloorPlan(room.floorPlan)).length}
onEdit={canEdit ? () => handleEditClick(room.id) : undefined} onEdit={canEdit ? () => handleEditClick(room.id) : undefined}
onDelete={canDelete ? () => handleDeleteClick(room.id, room.name) : undefined} onDelete={canDelete ? () => handleDeleteClick(room.id, room.name) : undefined}
showDeleteIcon={deleteMode && canDelete} showDeleteIcon={deleteMode && canDelete}

View file

@ -4,14 +4,7 @@ import { useState } from "react";
import { Button } from "@/src/components/ui/button"; import { Button } from "@/src/components/ui/button";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import RoomsListView from "./rooms-list-view"; import RoomsListView from "./rooms-list-view";
import type { Room } from "../../types/room.types";
type Room = {
id: string;
name: string;
createdBy: string;
createdAt: Date;
updatedAt: Date;
};
interface RoomsViewProps { interface RoomsViewProps {
rooms: Room[]; rooms: Room[];

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { POST } from "@/src/app/api/stripe/checkout/course/route"; import { POST } from "@/src/app/api/stripe/checkout/course/route";
vi.mock("@/src/lib/auth/getUser", () => ({ vi.mock("@/src/lib/auth/getUser", () => ({
@ -18,15 +18,46 @@ vi.mock("@/src/lib/prisma", () => ({
})); }));
vi.mock("@/src/lib/stripe", () => ({ vi.mock("@/src/lib/stripe", () => ({
stripe: { checkout: { sessions: { create: vi.fn() } } }, stripe: { checkout: { sessions: { create: vi.fn(), retrieve: vi.fn(), expire: vi.fn() } } },
getPlatformFeeCents: vi.fn().mockReturnValue(50), getPlatformFeeCents: vi.fn().mockReturnValue(50),
eurosToCents: vi.fn().mockReturnValue(2900), eurosToCents: vi.fn().mockReturnValue(2900),
})); }));
// Rabattcodes nur für den Test, dass ein Fehler bei der Platzwahl die
// Reservierung wieder freigibt.
vi.mock("@/src/modules/settings/lib/discount-validation", () => ({
reserveDiscountCode: vi.fn(),
releaseDiscountReservation: vi.fn().mockResolvedValue(undefined),
calculateDiscountedPriceCents: () => ({ discountedPriceCents: 2000, discountAmountCents: 900 }),
}));
// Kurse ohne Platzwahl liefern null - so bleiben alle übrigen Tests unberührt.
vi.mock("@/src/modules/courses/lib/course-spots", () => ({
getCourseSpots: vi.fn().mockResolvedValue(null),
getOpenCheckoutHolds: vi.fn().mockResolvedValue([]),
attachSessionToHold: vi.fn().mockResolvedValue(undefined),
holdSpot: vi.fn(),
releaseSpotHold: vi.fn().mockResolvedValue(undefined),
spotHolderKey: (p: { userId?: string | null; guestEmail?: string | null }) =>
p.userId ? `user:${p.userId}` : `guest:${p.guestEmail}`,
SPOT_HOLD_MINUTES: 30,
}));
import { getUserData, isUser } from "@/src/lib/auth/getUser"; import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email"; import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
import { prisma } from "@/src/lib/prisma"; import { prisma } from "@/src/lib/prisma";
import { stripe, getPlatformFeeCents } from "@/src/lib/stripe"; import { stripe, getPlatformFeeCents } from "@/src/lib/stripe";
import {
attachSessionToHold,
getCourseSpots,
getOpenCheckoutHolds,
holdSpot,
releaseSpotHold,
} from "@/src/modules/courses/lib/course-spots";
import {
reserveDiscountCode,
releaseDiscountReservation,
} from "@/src/modules/settings/lib/discount-validation";
let ipCounter = 0; let ipCounter = 0;
@ -230,3 +261,187 @@ describe("POST /api/stripe/checkout/course – Checkout-Seite", () => {
expect(retry.billing_address_collection).toBe("required"); expect(retry.billing_address_collection).toBe("required");
}); });
}); });
describe("POST /api/stripe/checkout/course – Platzwahl", () => {
const reformer = (id: string, label: string) => ({
id,
type: "reformer" as const,
x: 1,
y: 1.5,
rotation: 0 as const,
label,
});
beforeEach(() => {
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
vi.mocked(getCourseSpots).mockResolvedValue([reformer("spot-1", "1"), reformer("spot-2", "2")]);
vi.mocked(holdSpot).mockReset();
vi.mocked(releaseSpotHold).mockClear();
vi.mocked(attachSessionToHold).mockClear();
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
vi.mocked(stripe.checkout.sessions.retrieve).mockReset();
vi.mocked(stripe.checkout.sessions.expire).mockReset();
vi.mocked(releaseDiscountReservation).mockClear();
});
afterEach(() => {
vi.mocked(getCourseSpots).mockResolvedValue(null);
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
});
it("verlangt einen Platz, bevor es zu Stripe geht", async () => {
const res = await POST(makeRequest({ courseId: "course-1" }));
expect(res.status).toBe(400);
expect((await res.json()).spotUnavailable).toBe(true);
expect(holdSpot).not.toHaveBeenCalled();
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("lehnt einen Platz ab, den gerade jemand anderes bucht", async () => {
vi.mocked(holdSpot).mockResolvedValue({ held: false, reason: "Platz gerade vergeben" });
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(res.status).toBe(409);
expect(await res.json()).toMatchObject({
error: "Platz gerade vergeben",
spotUnavailable: true,
});
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("hält den Platz und lässt die Session mit dem Hold verfallen", async () => {
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(res.status).toBe(200);
expect(vi.mocked(holdSpot).mock.calls[0][1]).toMatchObject({
courseId: "course-1",
spotId: "spot-2",
holderKey: "user:user-1",
});
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
expires_at?: number;
metadata: Record<string, string>;
line_items: { price_data: { product_data: { description: string } } }[];
};
expect(args.metadata).toMatchObject({ spotId: "spot-2", spotHoldId: "hold-1" });
expect(args.line_items[0].price_data.product_data.description).toContain("Reformer 2");
const inMinutes = (args.expires_at! * 1000 - Date.now()) / 60_000;
expect(inMinutes).toBeGreaterThan(29);
expect(inMinutes).toBeLessThanOrEqual(30);
});
it("gibt den Platz frei, wenn Stripe die Session nicht anlegt", async () => {
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
vi.mocked(stripe.checkout.sessions.create).mockRejectedValueOnce(new Error("Stripe down"));
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
expect(res.status).toBe(500);
expect(releaseSpotHold).toHaveBeenCalledWith(prisma, "hold-1");
consoleError.mockRestore();
});
it("setzt ohne Platzwahl kein Ablaufdatum - Stripes Standard bleibt", async () => {
vi.mocked(getCourseSpots).mockResolvedValue(null);
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
expect(res.status).toBe(200);
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
expires_at?: number;
metadata: Record<string, string>;
};
expect(args.expires_at).toBeUndefined();
expect(args.metadata.spotId).toBe("");
expect(holdSpot).not.toHaveBeenCalled();
});
it("gibt den Rabattcode frei, wenn die Platzwahl mit einem Fehler abbricht", async () => {
vi.mocked(reserveDiscountCode).mockResolvedValue({
reserved: true,
reservationToken: "tok-1",
discountCode: { id: "code-1", type: "percent", value: 30 },
} as never);
vi.mocked(holdSpot).mockRejectedValue(new Error("DB weg"));
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const res = await POST(
makeRequest({ courseId: "course-1", spotId: "spot-1", discountCode: "PILATES30" })
);
expect(res.status).toBe(500);
expect(releaseDiscountReservation).toHaveBeenCalledWith(prisma, "tok-1");
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
consoleError.mockRestore();
});
it("hängt die neue Session an den Hold", async () => {
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
vi.mocked(stripe.checkout.sessions.create).mockResolvedValueOnce({
id: "cs_neu",
url: "https://checkout.stripe.com/neu",
} as never);
await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
expect(attachSessionToHold).toHaveBeenCalledWith(prisma, "hold-1", "cs_neu");
});
it("beendet einen alten, noch offenen Checkout derselben Person", async () => {
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
{ id: "hold-alt", stripeSessionId: "cs_alt" },
]);
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
id: "cs_alt",
status: "open",
} as never);
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(stripe.checkout.sessions.expire).toHaveBeenCalledWith(
"cs_alt",
{},
{ stripeAccount: "acct_123" }
);
expect(releaseSpotHold).toHaveBeenCalledWith(prisma, "hold-alt");
});
it("lässt den Platz eines schon bezahlten Checkouts stehen, bis der Webhook kommt", async () => {
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
{ id: "hold-alt", stripeSessionId: "cs_alt" },
]);
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
id: "cs_alt",
status: "complete",
} as never);
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(stripe.checkout.sessions.expire).not.toHaveBeenCalled();
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
});
it("lässt den alten Hold im Zweifel stehen, wenn Stripe nicht antwortet", async () => {
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
{ id: "hold-alt", stripeSessionId: "cs_alt" },
]);
vi.mocked(stripe.checkout.sessions.retrieve).mockRejectedValue(new Error("Timeout"));
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(res.status).toBe(200);
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
consoleWarn.mockRestore();
});
});

View file

@ -20,6 +20,7 @@ vi.mock("@/src/lib/prisma", () => ({
course: { findUnique: vi.fn() }, course: { findUnique: vi.fn() },
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), count: vi.fn() }, courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), count: vi.fn() },
courseWaitlistEntry: { deleteMany: vi.fn() }, courseWaitlistEntry: { deleteMany: vi.fn() },
courseSpotHold: { deleteMany: vi.fn(), updateMany: vi.fn() },
business: { findUnique: vi.fn(), update: vi.fn() }, business: { findUnique: vi.fn(), update: vi.fn() },
}, },
})); }));
@ -53,8 +54,16 @@ import { releaseDiscountReservation } from "@/src/modules/settings/lib/discount-
// Die Datenbank-Transaktion der Kapazitätsprüfung. // Die Datenbank-Transaktion der Kapazitätsprüfung.
const tx = { const tx = {
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), count: vi.fn(), upsert: vi.fn() }, courseBooking: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
upsert: vi.fn(),
},
course: { findUnique: vi.fn() }, course: { findUnique: vi.fn() },
room: { findUnique: vi.fn() },
courseSpotHold: { findMany: vi.fn() },
}; };
function request() { function request() {
@ -112,6 +121,9 @@ beforeEach(() => {
tx.courseBooking.count.mockResolvedValue(0); tx.courseBooking.count.mockResolvedValue(0);
tx.courseBooking.upsert.mockResolvedValue({ id: "booking-1" }); tx.courseBooking.upsert.mockResolvedValue({ id: "booking-1" });
tx.course.findUnique.mockResolvedValue({ maxParticipants: 10 }); tx.course.findUnique.mockResolvedValue({ maxParticipants: 10 });
tx.courseBooking.findMany.mockResolvedValue([]);
tx.courseSpotHold.findMany.mockResolvedValue([]);
tx.room.findUnique.mockResolvedValue(null);
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({ vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
application_fee_amount: 50, application_fee_amount: 50,
@ -298,3 +310,97 @@ describe("Stripe-Webhook – nie bezahlt und Gebühren", () => {
expect(recordStripeFeeForCharge).not.toHaveBeenCalled(); expect(recordStripeFeeForCharge).not.toHaveBeenCalled();
}); });
}); });
describe("Stripe-Webhook – Platzwahl", () => {
const reformer = (id: string, label: string, x: number) => ({
id,
type: "reformer",
x,
y: 1.5,
rotation: 0,
label,
});
beforeEach(() => {
tx.course.findUnique.mockResolvedValue({
maxParticipants: 10,
spotBooking: true,
room: "room-1",
});
tx.room.findUnique.mockResolvedValue({
floorPlan: {
width: 8,
depth: 6,
items: [reformer("spot-1", "1", 1), reformer("spot-2", "2", 2)],
},
});
});
const spotSession = (type = "checkout.session.completed", paymentStatus = "paid") =>
sessionEvent(type, {
account: "acct_studio",
paymentStatus,
metadata: { spotId: "spot-1", spotHoldId: "hold-1" },
});
it("schreibt den gewählten Platz an die Buchung und gibt den Hold frei", async () => {
await deliver(spotSession(), "whsec_connect");
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
paymentStatus: "paid",
spotId: "spot-1",
spotLabel: "Reformer 1",
});
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({ where: { id: "hold-1" } });
});
it("gibt den nächsten freien Platz, wenn der Wunschplatz inzwischen bezahlt ist", async () => {
tx.courseBooking.findMany.mockResolvedValue([{ spotId: "spot-1" }]);
await deliver(spotSession(), "whsec_connect");
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
paymentStatus: "paid",
spotId: "spot-2",
spotLabel: "Reformer 2",
});
expect(refundStripePayment).not.toHaveBeenCalled();
});
it("erstattet wie bei einem vollen Kurs, wenn kein Platz mehr frei ist", async () => {
tx.courseBooking.count.mockResolvedValue(2);
tx.courseBooking.findMany.mockResolvedValue([{ spotId: "spot-1" }, { spotId: "spot-2" }]);
await deliver(spotSession(), "whsec_connect");
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
paymentStatus: "failed",
});
expect(refundStripePayment).toHaveBeenCalledWith({
paymentIntentId: "pi_1",
stripeAccountId: "acct_studio",
refundApplicationFee: true,
});
});
it("hält den Platz bei SEPA, bis die Zahlung ankommt", async () => {
await deliver(spotSession("checkout.session.completed", "unpaid"), "whsec_connect");
expect(prisma.$transaction).not.toHaveBeenCalled();
const call = vi.mocked(prisma.courseSpotHold.updateMany).mock.calls[0][0] as {
where: { id: string };
data: { expiresAt: Date; status: string };
};
expect(call.where).toEqual({ id: "hold-1" });
// Ein neuer Checkout derselben Person darf diesen Platz nicht freigeben.
expect(call.data.status).toBe("awaiting_payment");
const days = (call.data.expiresAt.getTime() - Date.now()) / 86_400_000;
expect(days).toBeGreaterThan(13);
});
it("gibt den Platz frei, wenn die Session abläuft", async () => {
await deliver(spotSession("checkout.session.expired", "unpaid"), "whsec_connect");
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({ where: { id: "hold-1" } });
});
});

View file

@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
assignSpot,
extendSpotHold,
getCourseSpots,
getHeldSpotIds,
holdSpot,
spotHolderKey,
} from "@/src/modules/courses/lib/course-spots";
import type { FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
const db = {
room: { findUnique: vi.fn() },
courseBooking: { findMany: vi.fn(), findFirst: vi.fn() },
courseSpotHold: {
findMany: vi.fn(),
findUnique: vi.fn(),
deleteMany: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
},
};
const spot = (id: string, label: string): FloorPlanItem => ({
id,
type: "reformer",
x: 1,
y: 1.5,
rotation: 0,
label,
});
const spots = [spot("s1", "1"), spot("s2", "2"), spot("s3", "3")];
beforeEach(() => {
vi.clearAllMocks();
db.courseBooking.findMany.mockResolvedValue([]);
db.courseBooking.findFirst.mockResolvedValue(null);
db.courseSpotHold.findMany.mockResolvedValue([]);
db.courseSpotHold.deleteMany.mockResolvedValue({ count: 0 });
db.courseSpotHold.create.mockResolvedValue({ id: "hold-1" });
});
describe("getCourseSpots", () => {
it("liest den Raum gar nicht erst, wenn der Kurs keine Platzwahl hat", async () => {
expect(await getCourseSpots(db as never, { spotBooking: false, room: "room-1" })).toBeNull();
expect(db.room.findUnique).not.toHaveBeenCalled();
});
it("behandelt einen Raum ohne Geräte wie einen Kurs ohne Platzwahl", async () => {
db.room.findUnique.mockResolvedValue({ floorPlan: null });
expect(await getCourseSpots(db as never, { spotBooking: true, room: "room-1" })).toBeNull();
});
it("liefert die buchbaren Geräte des Raums", async () => {
db.room.findUnique.mockResolvedValue({
floorPlan: { width: 8, depth: 6, items: [spot("s2", "2"), spot("s1", "1")] },
});
const result = await getCourseSpots(db as never, { spotBooking: true, room: "room-1" });
expect(result?.map((s) => s.id)).toEqual(["s1", "s2"]);
});
});
describe("assignSpot", () => {
it("gibt den Wunschplatz, wenn er frei ist", async () => {
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s2",
holdId: "hold-1",
});
expect(result).toEqual({ spotId: "s2", spotLabel: "Reformer 2", changed: false });
});
it("weicht auf einen freien Platz aus, den niemand gerade im Checkout hält", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotId: "s1" }]);
db.courseSpotHold.findMany.mockResolvedValue([{ spotId: "s2" }]);
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s1",
holdId: "hold-1",
});
expect(result).toEqual({ spotId: "s3", spotLabel: "Reformer 3", changed: true });
// Der eigene Hold zählt nicht als "von jemand anderem gehalten".
expect(db.courseSpotHold.findMany.mock.calls[0][0].where.id).toEqual({ not: "hold-1" });
});
it("nimmt notfalls einen gehaltenen Platz - bezahlt schlägt Checkout", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotId: "s1" }, { spotId: "s3" }]);
db.courseSpotHold.findMany.mockResolvedValue([{ spotId: "s2" }]);
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s1",
holdId: null,
});
expect(result?.spotId).toBe("s2");
});
it("liefert null, wenn alle Plätze bezahlt sind", async () => {
db.courseBooking.findMany.mockResolvedValue(spots.map((s) => ({ spotId: s.id })));
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s1",
holdId: null,
});
expect(result).toBeNull();
});
it("weist bei alten Sessions ohne Wunschplatz den ersten freien zu", async () => {
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: null,
holdId: null,
});
expect(result).toEqual({ spotId: "s1", spotLabel: "Reformer 1", changed: false });
});
});
describe("holdSpot", () => {
const params = {
courseId: "c1",
spotId: "s1",
holderKey: spotHolderKey({ userId: "u1" }),
expiresAt: new Date(Date.now() + 30 * 60_000),
};
it("räumt nur abgelaufene Holds weg - eigene Holds anderer Checkouts bleiben", async () => {
const result = await holdSpot(db as never, params);
expect(result).toEqual({ held: true, holdId: "hold-1" });
const where = db.courseSpotHold.deleteMany.mock.calls[0][0].where;
expect(where).toEqual({ courseId: "c1", expiresAt: { lte: expect.any(Date) } });
});
it("hält keinen Platz, der schon bezahlt ist", async () => {
db.courseBooking.findFirst.mockResolvedValue({ id: "b1" });
const result = await holdSpot(db as never, params);
expect(result.held).toBe(false);
expect(db.courseSpotHold.create).not.toHaveBeenCalled();
});
it("verliert gegen einen gleichzeitigen Checkout für denselben Platz", async () => {
db.courseSpotHold.create.mockRejectedValue(
Object.assign(new Error("Unique constraint failed"), { code: "P2002" })
);
db.courseSpotHold.findUnique.mockResolvedValue({ holderKey: "user:jemand-anderes" });
const result = await holdSpot(db as never, params);
expect(result).toMatchObject({
held: false,
reason: expect.stringContaining("jemand anderem"),
});
});
it("sagt der Person, dass ihre eigene Zahlung für den Platz noch läuft", async () => {
db.courseSpotHold.create.mockRejectedValue(
Object.assign(new Error("Unique constraint failed"), { code: "P2002" })
);
db.courseSpotHold.findUnique.mockResolvedValue({ holderKey: "user:u1" });
const result = await holdSpot(db as never, params);
expect(result).toMatchObject({ held: false, reason: expect.stringContaining("Ihre Zahlung") });
});
});
describe("Hold-Status", () => {
it("markiert einen verlängerten Hold als abgeschlossenen Checkout", async () => {
const until = new Date(Date.now() + 14 * 86_400_000);
await extendSpotHold(db as never, "hold-1", until);
expect(db.courseSpotHold.updateMany).toHaveBeenCalledWith({
where: { id: "hold-1" },
data: { expiresAt: until, status: "awaiting_payment" },
});
});
it("blendet für die Person nur ihre offenen Checkouts aus", async () => {
await getHeldSpotIds(db as never, "c1", { exceptHolderKey: "user:u1" });
expect(db.courseSpotHold.findMany.mock.calls[0][0].where.NOT).toEqual({
holderKey: "user:u1",
status: "checkout",
});
});
});
describe("spotHolderKey", () => {
it("normalisiert Gast-E-Mails, damit derselbe Gast seinen alten Hold ersetzt", () => {
expect(spotHolderKey({ guestEmail: " Gast@Example.com " })).toBe("guest:gast@example.com");
});
});

View file

@ -0,0 +1,122 @@
import { describe, it, expect } from "vitest";
import {
bookableSpots,
findFreePosition,
itemFootprint,
nextLabel,
parseFloorPlan,
placeInRoom,
spotDisplayName,
type FloorPlan,
type FloorPlanItem,
} from "@/src/modules/rooms/lib/floor-plan";
let idCounter = 0;
const item = (overrides: Partial<FloorPlanItem> = {}): FloorPlanItem => ({
id: `item-${++idCounter}`,
type: "reformer",
x: 1,
y: 2,
rotation: 0,
label: "1",
...overrides,
});
const plan = (items: FloorPlanItem[]): FloorPlan => ({ width: 8, depth: 6, items });
describe("parseFloorPlan", () => {
it("liefert null für Räume ohne oder mit kaputtem Grundriss", () => {
expect(parseFloorPlan(null)).toBeNull();
expect(parseFloorPlan({})).toBeNull();
expect(parseFloorPlan({ width: 8, depth: 6, items: [{ id: "x", type: "sofa" }] })).toBeNull();
});
it("lehnt doppelte Platznummern innerhalb eines Gerätetyps ab", () => {
const duplicate = plan([item({ label: "1" }), item({ label: "1" })]);
expect(parseFloorPlan(duplicate)).toBeNull();
});
it("erlaubt dieselbe Nummer bei verschiedenen Gerätetypen und Deko ohne Nummer", () => {
const mixed = plan([
item({ type: "reformer", label: "1" }),
item({ type: "mat", label: "1" }),
item({ type: "mirror", label: "" }),
]);
expect(parseFloorPlan(mixed)?.items).toHaveLength(3);
});
it("verlangt bei buchbaren Geräten eine Nummer", () => {
expect(parseFloorPlan(plan([item({ label: "" })]))).toBeNull();
});
});
describe("bookableSpots", () => {
it("sortiert nach Gerätetyp und Nummer und lässt Deko weg", () => {
const spots = bookableSpots(
plan([
item({ type: "mat", label: "1" }),
item({ type: "reformer", label: "10" }),
item({ type: "trainer", label: "" }),
item({ type: "reformer", label: "2" }),
])
);
expect(spots.map(spotDisplayName)).toEqual(["Reformer 2", "Reformer 10", "Matte 1"]);
});
it("liefert für keinen Grundriss eine leere Liste", () => {
expect(bookableSpots(null)).toEqual([]);
});
});
describe("nextLabel", () => {
it("füllt Lücken in der Nummerierung pro Gerätetyp", () => {
const items = [
item({ label: "1" }),
item({ label: "2" }),
item({ label: "4" }),
item({ type: "mat", label: "3" }),
];
expect(nextLabel(items, "reformer")).toBe("3");
expect(nextLabel(items, "mat")).toBe("1");
});
it("vergibt an Deko keine Nummer", () => {
expect(nextLabel([], "door")).toBe("");
});
});
describe("placeInRoom", () => {
it("hält ein Gerät vollständig innerhalb der Wände", () => {
// Reformer ungedreht: 0,7 m breit, 2,4 m lang
expect(placeInRoom(item(), { x: -3, y: 100 }, plan([]))).toEqual({ x: 0.35, y: 4.8 });
});
it("berücksichtigt die Drehung beim Einpassen", () => {
const turned = item({ rotation: 90 });
expect(itemFootprint(turned)).toEqual({ w: 2.4, h: 0.7 });
expect(placeInRoom(turned, { x: 0, y: 0 }, plan([]))).toEqual({ x: 1.2, y: 0.35 });
});
it("rastet auf 10 cm ein, ohne Gleitkomma-Reste", () => {
const pos = placeInRoom(item(), { x: 2.34, y: 3.26 }, plan([]));
expect(pos).toEqual({ x: 2.3, y: 3.3 });
});
});
describe("findFreePosition", () => {
it("reiht neue Geräte nebeneinander auf, statt sie zu stapeln", () => {
const room = plan([]);
for (let i = 0; i < 6; i++) {
const pos = findFreePosition(room, { type: "reformer", rotation: 0 });
room.items.push(item({ ...pos, label: String(i + 1) }));
}
for (const a of room.items) {
for (const b of room.items) {
if (a === b) continue;
const overlapX = Math.abs(a.x - b.x) < 0.7;
const overlapY = Math.abs(a.y - b.y) < 2.4;
expect(overlapX && overlapY).toBe(false);
}
}
});
});

View file

@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({
prisma: {
room: { findUnique: vi.fn(), update: vi.fn() },
courseBooking: { findMany: vi.fn() },
course: { findMany: vi.fn(), updateMany: 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 }) => u.id,
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/src/modules/courses/lib/public-courses-cache", () => ({
refreshPublicCourses: vi.fn(),
}));
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
import { updateRoom } from "@/src/modules/rooms/actions/room-actions";
import { refreshPublicCourses } from "@/src/modules/courses/lib/public-courses-cache";
import type { FloorPlan, FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
const reformer = (id: string, label: string, x: number): FloorPlanItem => ({
id,
type: "reformer",
x,
y: 1.5,
rotation: 0,
label,
});
const plan = (items: FloorPlanItem[]): FloorPlan => ({ width: 8, depth: 6, items });
const threeReformers = plan([
reformer("r1", "1", 1),
reformer("r2", "2", 2),
reformer("r3", "3", 3),
]);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getUserData).mockResolvedValue({ id: "mgr-1", role: "manager" } as never);
vi.mocked(prisma.room.findUnique).mockResolvedValue({
id: "room-1",
createdBy: "mgr-1",
floorPlan: threeReformers,
} as never);
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([]);
vi.mocked(prisma.course.findMany).mockResolvedValue([]);
});
describe("updateRoom – gebuchte Plätze", () => {
it("verhindert das Entfernen eines Geräts mit kommender Buchung", async () => {
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([{ spotId: "r3" }] as never);
const result = await updateRoom("room-1", {
name: "Reformer-Raum",
floorPlan: plan([reformer("r1", "1", 1), reformer("r2", "2", 2)]),
});
expect(result).toMatchObject({ success: false });
expect(result.error).toContain("Reformer 3");
expect(prisma.room.update).not.toHaveBeenCalled();
});
it("verhindert das Umnummerieren eines gebuchten Geräts", async () => {
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([{ spotId: "r1" }] as never);
const result = await updateRoom("room-1", {
name: "Reformer-Raum",
floorPlan: plan([reformer("r1", "9", 1), reformer("r2", "2", 2), reformer("r3", "3", 3)]),
});
expect(result.success).toBe(false);
expect(prisma.room.update).not.toHaveBeenCalled();
});
it("erlaubt das Verschieben gebuchter Geräte, ohne Buchungen zu prüfen", async () => {
const moved = plan([reformer("r1", "1", 5), reformer("r2", "2", 6), reformer("r3", "3", 7)]);
const result = await updateRoom("room-1", { name: "Reformer-Raum", floorPlan: moved });
expect(result.success).toBe(true);
expect(prisma.courseBooking.findMany).not.toHaveBeenCalled();
expect(vi.mocked(prisma.room.update).mock.calls[0][0].data.floorPlan).toEqual(moved);
});
});
describe("updateRoom – Teilnehmerzahl", () => {
it("senkt die Teilnehmerzahl kommender Platzwahl-Kurse auf die neue Platzanzahl", async () => {
vi.mocked(prisma.course.findMany).mockResolvedValue([
{ id: "c1", businessId: "biz-1", isStandingOrder: false },
{ id: "c2", businessId: "biz-1", isStandingOrder: false },
] as never);
const result = await updateRoom("room-1", {
name: "Reformer-Raum",
floorPlan: plan([reformer("r1", "1", 1), reformer("r2", "2", 2)]),
});
expect(result.success).toBe(true);
expect(result.message).toContain("2 kommende Kurse");
expect(vi.mocked(prisma.course.findMany).mock.calls[0][0]!.where).toMatchObject({
room: "room-1",
spotBooking: true,
maxParticipants: { gt: 2 },
});
expect(prisma.course.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["c1", "c2"] } },
data: { maxParticipants: 2 },
});
expect(refreshPublicCourses).toHaveBeenCalledWith("biz-1", "biz-1");
});
it("lässt die Teilnehmerzahl in Ruhe, wenn der Grundriss ganz entfernt wird", async () => {
vi.mocked(prisma.room.findUnique).mockResolvedValue({
id: "room-1",
createdBy: "mgr-1",
floorPlan: null,
} as never);
const result = await updateRoom("room-1", { name: "Matten-Raum", floorPlan: null });
expect(result.success).toBe(true);
expect(prisma.course.findMany).not.toHaveBeenCalled();
});
});