diff --git a/prisma/migrations/20261002120000_room_floor_plan_spots/migration.sql b/prisma/migrations/20261002120000_room_floor_plan_spots/migration.sql new file mode 100644 index 0000000..187cdf8 --- /dev/null +++ b/prisma/migrations/20261002120000_room_floor_plan_spots/migration.sql @@ -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; + diff --git a/prisma/migrations/20261002150000_spot_hold_session_state/migration.sql b/prisma/migrations/20261002150000_spot_hold_session_state/migration.sql new file mode 100644 index 0000000..5be7f4f --- /dev/null +++ b/prisma/migrations/20261002150000_spot_hold_session_state/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "CourseSpotHold" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'checkout', +ADD COLUMN "stripeSessionId" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 39eae66..96d4ff5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -172,9 +172,13 @@ model Course { createdBy String // Manager ID who created the course businessId 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) bookings CourseBooking[] waitlistEntries CourseWaitlistEntry[] + spotHolds CourseSpotHold[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -190,6 +194,10 @@ model Room { id String @id @default(uuid()) name String 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()) updatedAt DateTime @updatedAt @@ -226,6 +234,8 @@ model CourseBooking { cancellationReason String? 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) + 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) user User? @relation(fields: [userId], references: [id], onDelete: Cascade) guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull) @@ -238,6 +248,31 @@ model CourseBooking { @@index([userId]) @@index([guestLeadId]) @@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:" oder "guest:": 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 diff --git a/src/app/api/stripe/checkout/course/route.ts b/src/app/api/stripe/checkout/course/route.ts index f8fb3f3..8a12f39 100644 --- a/src/app/api/stripe/checkout/course/route.ts +++ b/src/app/api/stripe/checkout/course/route.ts @@ -11,6 +11,16 @@ import { releaseDiscountReservation, calculateDiscountedPriceCents, } 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 @@ -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. // 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 @@ -79,6 +123,8 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; * * Auth-Flow → Body: { courseId: 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) { try { @@ -102,8 +148,9 @@ export async function POST(request: NextRequest) { guestEmail?: string; guestName?: string; discountCode?: string; + spotId?: string; }; - const { courseId, guestEmail, guestName, discountCode } = body; + const { courseId, guestEmail, guestName, discountCode, spotId } = body; if (!courseId) { return NextResponse.json({ error: "courseId fehlt" }, { status: 400 }); @@ -280,51 +327,119 @@ export async function POST(request: NextRequest) { reservationToken = reservation.reservationToken; } - // 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 holderKey = spotHolderKey({ + userId: isGuest ? null : userData!.id, + guestEmail: isGuest ? guestEmail : null, + }); + let spotHoldId: string | null = null; + let spotLabel: string | null = null; - 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", - }; - - // 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 } } - : {}), + // Ab hier ist ggf. ein Rabattcode reserviert und gleich ein Platz gehalten. + // Jeder Fehler bis zur fertigen Session muss beides wieder freigeben, sonst + // blieben sie blockiert, ohne dass je eine Buchung zustande kommt - eine + // nie angelegte Session meldet auch kein checkout.session.expired. + const releaseReservations = async () => { + if (reservationToken) { + await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => { + console.error( + `Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagenem ` + + `Checkout-Start nicht freigegeben werden:`, + releaseError + ); + }); + } + if (spotHoldId) { + await releaseSpotHold(prisma, spotHoldId).catch((releaseError) => { + console.error( + `Platz-Hold ${spotHoldId} konnte nach fehlgeschlagenem Checkout-Start nicht ` + + `freigegeben werden:`, + releaseError + ); + }); + } }; let session; 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( { mode: "payment", @@ -337,7 +452,7 @@ export async function POST(request: NextRequest) { currency: "eur", product_data: { 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, }, @@ -367,27 +482,30 @@ export async function POST(request: NextRequest) { }, metadata, 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}`, cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`, }, brandingSettings, course.business.stripeAccountId ); - } catch (stripeError) { - // Reservierung wurde bereits vor der Session-Erstellung angelegt (siehe - // oben) - schlägt die Session-Erstellung selbst fehl, muss der belegte - // Slot wieder freigegeben werden, sonst bliebe er dauerhaft blockiert, - // ohne dass je eine Buchung zustande kommt. - if (reservationToken) { - await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => { - console.error( - `Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagener ` + - `Stripe-Session nicht freigegeben werden:`, - releaseError - ); - }); - } - throw stripeError; + } catch (error) { + await releaseReservations(); + throw error; + } + + // Damit ein späterer Checkout derselben Person diese Session beenden kann + // (siehe endOpenCheckouts). Scheitert das, bleibt der Platz trotzdem bis + // zum Ablauf gehalten - kein Grund, die fertige Session zu verwerfen. + if (spotHoldId && session.id) { + await attachSessionToHold(prisma, spotHoldId, session.id).catch((err) => + console.error(`Session konnte nicht an Platz-Hold ${spotHoldId} gehängt werden:`, err) + ); } return NextResponse.json({ diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index f80628f..eebc2d1 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -19,6 +19,14 @@ import { confirmDiscountReservation, releaseDiscountReservation, } 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. @@ -52,7 +60,7 @@ async function findUserByEmailInsensitive(email: string) { * - checkout.session.async_payment_succeeded → die verzögerte Zahlung ist * angekommen, Buchung anlegen wie oben. * - 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. * - account.updated → Stripe Connect Account-Status synchronisieren. */ @@ -189,15 +197,29 @@ async function handleCheckoutCompleted( 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") { // Verzögerte Zahlungsart wie SEPA-Lastschrift: Der Kunde hat den Checkout // abgeschlossen, das Geld ist aber noch unterwegs. Gebucht wird erst bei // 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( `Session ${session.id} ist noch nicht bezahlt (${session.payment_status}) - ` + `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; } @@ -279,6 +301,7 @@ async function handleCheckoutCompleted( userId: registeredUser.id, session, payment, + spotRequest, discountCodeId, discountAmountCents, discountReservationToken, @@ -290,6 +313,7 @@ async function handleCheckoutCompleted( guestName: guestName || undefined, session, payment, + spotRequest, }); } else { const userId = session.metadata?.userId; @@ -328,6 +352,7 @@ async function handleCheckoutCompleted( userId: registeredUser.id, session, payment, + spotRequest, discountCodeId, discountAmountCents, discountReservationToken, @@ -339,6 +364,7 @@ async function handleCheckoutCompleted( guestName: session.metadata?.guestName || undefined, session, payment, + spotRequest, }); } else { console.error( @@ -369,6 +395,7 @@ async function handleCheckoutCompleted( userId, session, payment, + spotRequest, discountCodeId, discountAmountCents, discountReservationToken, @@ -403,6 +430,15 @@ async function handleCheckoutCompleted( 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"; let refundSucceeded = false; @@ -470,7 +506,10 @@ async function handleCheckoutCompleted( const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null; if (recipientEmail) { 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) { await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000); } else if (outcome.result === "duplicate_payment" && duplicateRefundSucceeded) { @@ -530,8 +569,17 @@ type CapacityOutcome = { | "course_not_found" | "duplicate_payment"; 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 // erneuter Zustellung DERSELBEN Checkout-Session (Stripe-Retry, manuelles // Redeliver im Dashboard) darf der Webhook den Datensatz auf KEINEN Fall per @@ -562,6 +610,7 @@ async function runCapacityTransactionUser(params: { userId: string; session: Stripe.Checkout.Session; payment: SessionPayment; + spotRequest?: SpotRequest; discountCodeId?: string | null; discountAmountCents?: number; discountReservationToken?: string | null; @@ -572,6 +621,7 @@ async function runCapacityTransactionUser(params: { userId, session, payment, + spotRequest = { requestedSpotId: null, holdId: null }, discountCodeId = null, discountAmountCents = 0, discountReservationToken = null, @@ -637,7 +687,7 @@ async function runCapacityTransactionUser(params: { const course = await tx.course.findUnique({ where: { id: courseId }, - select: { maxParticipants: true }, + select: { maxParticipants: true, spotBooking: true, room: true }, }); if (!course) return { result: "course_not_found" as const }; @@ -646,12 +696,21 @@ async function runCapacityTransactionUser(params: { 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({ where: { courseId_userId: { courseId, userId } }, update: { paymentStatus: "failed", ...paymentColumns(session.id, payment), + ...spotColumns(null), }, create: { courseId, @@ -668,6 +727,7 @@ async function runCapacityTransactionUser(params: { update: { paymentStatus: "paid", ...paymentColumns(session.id, payment), + ...spotColumns(spot), discountCodeId, discountAmount: discountCodeId ? discountAmountCents : null, }, @@ -676,6 +736,7 @@ async function runCapacityTransactionUser(params: { userId, paymentStatus: "paid", ...paymentColumns(session.id, payment), + ...spotColumns(spot), discountCodeId, 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" } ); @@ -714,8 +775,16 @@ async function runCapacityTransactionGuest(params: { guestName?: string; session: Stripe.Checkout.Session; payment: SessionPayment; + spotRequest?: SpotRequest; }): Promise { - const { courseId, guestEmail, guestName, session, payment } = params; + const { + courseId, + guestEmail, + guestName, + session, + payment, + spotRequest = { requestedSpotId: null, holdId: null }, + } = params; return prisma.$transaction( async (tx) => { @@ -742,7 +811,7 @@ async function runCapacityTransactionGuest(params: { const course = await tx.course.findUnique({ where: { id: courseId }, - select: { maxParticipants: true }, + select: { maxParticipants: true, spotBooking: true, room: true }, }); if (!course) return { result: "course_not_found" as const }; @@ -751,10 +820,16 @@ async function runCapacityTransactionGuest(params: { 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 // bei Überbuchung. upsert by email: ein Gast bekommt genau einen Lead-Eintrag, // 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({ where: { email: guestEmail }, update: {}, @@ -782,9 +857,10 @@ async function runCapacityTransactionGuest(params: { guestLeadId: guestLead.id, paymentStatus: "paid", ...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" } ); @@ -803,7 +879,8 @@ async function withTimeout(promise: Promise, ms: number): Promise + console.error(`Platz-Hold ${spotHoldId} konnte nicht freigegeben werden:`, error) + ); + } // Gibt einen beim Checkout-Start reservierten Rabattcode-Slot wieder frei // (siehe reserveDiscountCode), da diese Session nie bezahlt wurde. Läuft diff --git a/src/app/courses/book/[courseId]/success/page.tsx b/src/app/courses/book/[courseId]/success/page.tsx index 2efec3a..d3b48a2 100644 --- a/src/app/courses/book/[courseId]/success/page.tsx +++ b/src/app/courses/book/[courseId]/success/page.tsx @@ -4,7 +4,7 @@ import { Button } from "@/src/components/ui/button"; import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions"; import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding"; 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 { params: Promise<{ courseId: string }>; @@ -87,6 +87,14 @@ export default async function CourseBookingSuccessPage({ params, searchParams }: {course.business.address} )} + {result.success && result.spotLabel && ( +
+ + + Ihr Platz: {result.spotLabel} + +
+ )}
diff --git a/src/app/floor-plan-preview/page.tsx b/src/app/floor-plan-preview/page.tsx new file mode 100644 index 0000000..19aa2e4 --- /dev/null +++ b/src/app/floor-plan-preview/page.tsx @@ -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("r3"); + return ( +
+
+

Editor

+ +
+
+

Platzwahl

+ +
+
+ ); +} diff --git a/src/lib/mail/templates/booking-confirmation.ts b/src/lib/mail/templates/booking-confirmation.ts index 944436a..797544f 100644 --- a/src/lib/mail/templates/booking-confirmation.ts +++ b/src/lib/mail/templates/booking-confirmation.ts @@ -18,6 +18,10 @@ interface BookingConfirmationEmailParams { businessName: string; businessAddress?: string; 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; coverImageUrl?: string | null; companyName?: string; @@ -49,6 +53,8 @@ export function generateBookingConfirmationEmail({ businessName, businessAddress, amountPaidCents, + spotLabel, + spotChanged = false, myCoursesUrl, coverImageUrl, companyName = "Ihr Unternehmen", @@ -60,6 +66,10 @@ export function generateBookingConfirmationEmail({ const safeBusinessAddress = businessAddress ? escapeHtml(businessAddress) : ""; const safeTimeFrom = escapeHtml(timeFrom); 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 safeCoverImageUrl = coverImageUrl ? sanitizeUrl(coverImageUrl) : null; @@ -95,6 +105,18 @@ export function generateBookingConfirmationEmail({ { label: "Anbieter", valueHtml: safeBusinessName }, { label: "Termin", valueHtml: `${formattedDate}
${safeTimeFrom} – ${safeTimeTo} Uhr` }, ...(safeBusinessAddress ? [{ label: "Ort", valueHtml: safeBusinessAddress }] : []), + ...(safeSpotLabel + ? [ + { + label: "Ihr Platz", + valueHtml: `${safeSpotLabel}${ + spotChangedNote + ? `
${spotChangedNote}` + : "" + }`, + }, + ] + : []), ], })} @@ -138,7 +160,7 @@ vielen Dank für Ihre Buchung. Ihre Zahlung wurde erfolgreich verarbeitet. Kurs: ${courseName} Anbieter: ${businessName} 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! diff --git a/src/modules/courses/actions/booking-actions.ts b/src/modules/courses/actions/booking-actions.ts index 90569a9..ade7604 100644 --- a/src/modules/courses/actions/booking-actions.ts +++ b/src/modules/courses/actions/booking-actions.ts @@ -19,6 +19,8 @@ import { validateDiscountCode, calculateDiscountedPriceCents, } 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 = { bookingId: string; @@ -43,6 +45,8 @@ export type UserBooking = { trainers: string[]; trainersMap: Record; paymentStatus: string; + /** Gebuchter Platz bei Kursen mit Platzwahl, z.B. "Reformer 3". */ + spotLabel: string | null; isPast: boolean; businessName: string; /** Kontaktdaten des Standorts für den Detail-Dialog (Ticket: Email + Ort anzeigen). */ @@ -68,6 +72,7 @@ export async function getUserBookings(): Promise< select: { id: true, paymentStatus: true, + spotLabel: true, course: { select: { id: true, @@ -158,6 +163,7 @@ export async function getUserBookings(): Promise< trainers: b.course.trainers, trainersMap, paymentStatus: b.paymentStatus, + spotLabel: b.spotLabel, isPast: courseStart < now, businessName: b.course.business?.name ?? branding.companyName, 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. let paidPrice = course.price; let discountAmount: number | null = null; + let spotLabel: string | null = null; if (sessionId) { // findFirst statt findUnique mit stripeSessionId allein: eine URL, die // courseId von Kurs B mit der session_id von Kurs A kombiniert, würde // sonst Kurs B mit den Zahlungsdaten von Kurs A anzeigen. const booking = await prisma.courseBooking.findFirst({ where: { stripeSessionId: sessionId, courseId }, - select: { amountPaid: true, discountAmount: true }, + select: { amountPaid: true, discountAmount: true, spotLabel: true }, }); if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) { paidPrice = booking.amountPaid / 100; 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) { console.error("Error fetching course summary:", error); 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 { + 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) { const fetch = unstable_cache( async () => { diff --git a/src/modules/courses/actions/course-actions.ts b/src/modules/courses/actions/course-actions.ts index 4f0c527..2cabea0 100644 --- a/src/modules/courses/actions/course-actions.ts +++ b/src/modules/courses/actions/course-actions.ts @@ -20,9 +20,47 @@ import { TooManyInstancesError, type FrequencyType, } from "../utils/generate-instances"; +import { bookableSpots, parseFloorPlan } from "@/src/modules/rooms/lib/floor-plan"; 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 * @@ -75,6 +113,15 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ if (!creatorId) 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 const commonFields = { name: validatedData.name || "", @@ -88,6 +135,7 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ coverImage: validatedData.coverImage ?? null, maxParticipants: validatedData.maxParticipants || 0, price: validatedData.price ?? 0, + spotBooking: validatedData.spotBooking ?? false, frequency: validatedData.frequency || null, weekdays: validatedData.weekdays || [], weekdayTimings: validatedData.weekdayTimings @@ -458,6 +506,15 @@ export async function updateCourse( 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) const sharedUpdateFields = { name: validatedData.name || "", @@ -469,6 +526,7 @@ export async function updateCourse( coverImage: validatedData.coverImage ?? null, maxParticipants: validatedData.maxParticipants || 0, price: validatedData.price ?? 0, + spotBooking: validatedData.spotBooking ?? false, status, ...(validatedData.businessId !== undefined ? { businessId: validatedData.businessId || null } diff --git a/src/modules/courses/lib/course-spots.ts b/src/modules/courses/lib/course-spots.ts new file mode 100644 index 0000000..4957ee8 --- /dev/null +++ b/src/modules/courses/lib/course-spots.ts @@ -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[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 { + 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> { + 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> { + 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 { + 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 { + await db.courseSpotHold.updateMany({ where: { id: holdId }, data: { stripeSessionId } }); +} + +export async function releaseSpotHold(db: PrismaOrTx, holdId: string): Promise { + 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 { + 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 { + 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, + }; +} diff --git a/src/modules/courses/schemas/course-schema.ts b/src/modules/courses/schemas/course-schema.ts index 8505ff1..9894b85 100644 --- a/src/modules/courses/schemas/course-schema.ts +++ b/src/modules/courses/schemas/course-schema.ts @@ -53,6 +53,7 @@ export const courseSchema = z weekdayTimings: weekdayTimingsSchema, endDate: isoDateOptional, businessId: z.string().optional(), + spotBooking: z.boolean().optional(), }) .refine( (data) => { @@ -92,6 +93,7 @@ export const publishedCourseSchema = z weekdayTimings: weekdayTimingsSchema, endDate: isoDateOptional, businessId: z.string().optional(), + spotBooking: z.boolean().optional(), }) .refine( (data) => { diff --git a/src/modules/courses/types/course.types.ts b/src/modules/courses/types/course.types.ts index a1cdffe..52d44cc 100644 --- a/src/modules/courses/types/course.types.ts +++ b/src/modules/courses/types/course.types.ts @@ -25,6 +25,7 @@ export type Course = { createdBy: string; businessId: string | null; coverImage: string | null; + spotBooking: boolean; createdAt: Date; updatedAt: Date; }; diff --git a/src/modules/courses/ui/components/BookingDialog.tsx b/src/modules/courses/ui/components/BookingDialog.tsx index 7ad4834..d8ae3a5 100644 --- a/src/modules/courses/ui/components/BookingDialog.tsx +++ b/src/modules/courses/ui/components/BookingDialog.tsx @@ -1,19 +1,26 @@ "use client"; -import { useState, useEffect, useSyncExternalStore } from "react"; +import { useState, useEffect, useRef, useSyncExternalStore } from "react"; import Image from "next/image"; 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 { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/components/ui/dialog"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; 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 { getContrastTextColor } from "@/src/modules/settings/lib/contrast"; import { toast } from "sonner"; import { COURSE_LEVELS } from "../../lib/course-levels"; 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 // `true` im Browser. Damit haben Server- und initialer Client-Render denselben @@ -78,6 +85,10 @@ export interface PublicCourse { roomName?: string | null; coverImage?: string; 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" }) => { @@ -165,6 +176,13 @@ export const BookingDialog = ({ code: string; discountedPrice: number; } | 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(null); + const [isLoadingSpots, setIsLoadingSpots] = useState(false); + const [selectedSpotId, setSelectedSpotId] = useState(null); + const [highlightPicker, setHighlightPicker] = useState(false); + const spotPickerRef = useRef(null); const isAdminView = paymentStatus !== undefined; @@ -215,6 +233,52 @@ export const BookingDialog = ({ }; }, [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 // Stripe-Checkout-Abbruch (Zurück-Navigation) aus dem bfcache wiederhergestellt wird. useEffect(() => { @@ -251,6 +315,7 @@ export const BookingDialog = ({ try { const body: Record = { courseId: course.id }; if (appliedDiscount) body.discountCode = appliedDiscount.code; + if (selectedSpot) body.spotId = selectedSpot.id; if (opts?.asGuest) { const normalizedEmail = guestEmail.trim().toLowerCase(); if (!EMAIL_RE.test(normalizedEmail)) { @@ -268,7 +333,12 @@ export const BookingDialog = ({ body: JSON.stringify(body), }); - let data: { url?: string; error?: string; isGuestRequired?: boolean } = {}; + let data: { + url?: string; + error?: string; + isGuestRequired?: boolean; + spotUnavailable?: boolean; + } = {}; try { data = (await response.json()) as typeof data; } catch { @@ -288,6 +358,17 @@ export const BookingDialog = ({ 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) { toast.error(data.error || "Ein Fehler ist aufgetreten"); setIsSubmitting(false); @@ -407,6 +488,15 @@ export const BookingDialog = ({
)} + {isAdminView && course.spotLabel && ( +
+ + + Platz: {course.spotLabel} + +
+ )} + {business?.address && (
@@ -442,6 +532,40 @@ export const BookingDialog = ({
)} + {spotMode && (isBooked ? mySpot : !isFull) && ( +
+
+ +
+ {isBooked ? ( + Ihr Platz: {spotDisplayName(mySpot!)} + ) : ( + <> + Platz wählen +

+ Tippen Sie auf ein freies Gerät – es ist für Sie reserviert, sobald die + Zahlung durch ist. +

+ + )} +
+
+ +
+ )} + {course.description && (
@@ -505,6 +629,17 @@ export const BookingDialog = ({ Kurs + {formatEuro(serviceFee)} Servicegebühr )} + {spotMode && !isBooked && !isFull && ( + + {selectedSpot ? ( + <> + Ihr Platz: {spotDisplayName(selectedSpot)} + + ) : ( + "Noch kein Platz gewählt" + )} + + )}
{/* Admin-Ansicht: nur Status-Badge + Schließen */} @@ -543,7 +678,7 @@ export const BookingDialog = ({ > Schließen - {isCheckingStatus ? ( + {isCheckingStatus || isLoadingSpots ? ( @@ -570,12 +705,12 @@ export const BookingDialog = ({ // Gäste buchen über das Formular darunter - ein zweiter // "Buchen"-Button daneben wäre nur verwirrend. )} @@ -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" />
)} diff --git a/src/modules/courses/ui/components/UserBookingCard.tsx b/src/modules/courses/ui/components/UserBookingCard.tsx index 6045d1f..6aa84db 100644 --- a/src/modules/courses/ui/components/UserBookingCard.tsx +++ b/src/modules/courses/ui/components/UserBookingCard.tsx @@ -50,6 +50,7 @@ function toPublicCourse(b: UserBooking): PublicCourse { currentParticipants: b.currentParticipants, description: b.description, roomName: b.roomName, + spotLabel: b.spotLabel, coverImage: b.coverImage ?? undefined, trainerProfiles: b.trainerProfiles, }; @@ -182,9 +183,12 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => { {/* Datum + Raum */}
{dateFormatted} - + - {booking.roomName} + + {booking.roomName} + {booking.spotLabel && ` · ${booking.spotLabel}`} +
diff --git a/src/modules/courses/ui/views/course-create-view.tsx b/src/modules/courses/ui/views/course-create-view.tsx index 6dc830c..046787d 100644 --- a/src/modules/courses/ui/views/course-create-view.tsx +++ b/src/modules/courses/ui/views/course-create-view.tsx @@ -24,6 +24,7 @@ import { FileText, RefreshCw, Check, + LayoutGrid, } from "lucide-react"; import Image from "next/image"; import DeleteDialog from "@/src/components/layout/DeleteDialog"; @@ -49,6 +50,7 @@ import { StandingOrderScopeDialog, type StandingOrderScope, } from "../components/StandingOrderScopeDialog"; +import { bookableSpots, parseFloorPlan } from "@/src/modules/rooms/lib/floor-plan"; const LEVELS = [ { value: "any", label: "Jedes Niveau" }, @@ -128,6 +130,9 @@ const CourseCreateView = ({ const [maxParticipants, setMaxParticipants] = useState( 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( isEdit ? initialData!.isStandingOrder || false : false ); @@ -169,7 +174,9 @@ const CourseCreateView = ({ const [isPriceFocused, setIsPriceFocused] = useState(false); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); - const [rooms, setRooms] = useState>([]); + const [rooms, setRooms] = useState>( + [] + ); const [isLoadingRooms, setIsLoadingRooms] = useState(true); const [trainers, setTrainers] = useState>([]); const [isLoadingTrainers, setIsLoadingTrainers] = useState(true); @@ -186,6 +193,21 @@ const CourseCreateView = ({ const isStandingOrderRelated = 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 { deleteDialogOpen, setDeleteDialogOpen, @@ -225,6 +247,7 @@ const CourseCreateView = ({ const roomOptions = result.rooms.map((room) => ({ value: room.id, label: room.name, + spotCount: bookableSpots(parseFloorPlan(room.floorPlan)).length, })); setRooms(roomOptions); } else { @@ -291,6 +314,8 @@ const CourseCreateView = ({ newErrors.maxParticipants = "Mindestens 1 Teilnehmer erforderlich"; } else if (maxPart > 100) { 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); @@ -369,6 +394,9 @@ const CourseCreateView = ({ weekdayTimings: weekdays.cleanedTimings, endDate: endDate || 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 ( @@ -756,7 +784,7 @@ const CourseCreateView = ({ )} + {roomSpotCount > 0 && ( + + )}
setMaxParticipants(e.target.value)} /> - {errors.maxParticipants && ( + {errors.maxParticipants ? (

{errors.maxParticipants}

+ ) : ( + spotBookingActive && ( +

+ Höchstens {roomSpotCount} – so viele Plätze hat der Raum +

+ ) )}
diff --git a/src/modules/kunden/actions/kunde-detail-actions.ts b/src/modules/kunden/actions/kunde-detail-actions.ts index 442b2c2..7cb4086 100644 --- a/src/modules/kunden/actions/kunde-detail-actions.ts +++ b/src/modules/kunden/actions/kunde-detail-actions.ts @@ -32,6 +32,8 @@ export type KundeBooking = { trainers: string[]; trainersMap: Record; paymentStatus: string; + /** Gebuchter Platz bei Kursen mit Platzwahl, z.B. "Reformer 3". */ + spotLabel: string | null; isPast: boolean; businessName: string; businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string }; @@ -84,6 +86,7 @@ export async function getKundeDetail( select: { id: true, paymentStatus: true, + spotLabel: true, course: { select: { id: true, @@ -178,6 +181,7 @@ export async function getKundeDetail( trainers: b.course.trainers, trainersMap, paymentStatus: b.paymentStatus, + spotLabel: b.spotLabel, isPast: courseStart < now, businessName: b.course.business?.name ?? branding.companyName, businessBranding: { diff --git a/src/modules/kunden/ui/components/kunde-booking-card.tsx b/src/modules/kunden/ui/components/kunde-booking-card.tsx index 9ffe2ce..5f3cc08 100644 --- a/src/modules/kunden/ui/components/kunde-booking-card.tsx +++ b/src/modules/kunden/ui/components/kunde-booking-card.tsx @@ -46,6 +46,7 @@ function toPublicCourse(b: KundeBooking): PublicCourse { currentParticipants: b.currentParticipants, description: b.description, roomName: b.roomName, + spotLabel: b.spotLabel, coverImage: b.coverImage ?? undefined, trainerProfiles: b.trainerProfiles, }; @@ -132,9 +133,12 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => { {/* Datum + Raum */}
{dateFormatted} - + - {booking.roomName} + + {booking.roomName} + {booking.spotLabel && ` · ${booking.spotLabel}`} +
diff --git a/src/modules/rooms/actions/room-actions.ts b/src/modules/rooms/actions/room-actions.ts index e6c2154..5aca130 100644 --- a/src/modules/rooms/actions/room-actions.ts +++ b/src/modules/rooms/actions/room-actions.ts @@ -12,6 +12,20 @@ import { prisma } from "@/src/lib/prisma"; import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser"; import { roomSchema, type RoomFormData } from "../schemas/room-schema"; 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 @@ -68,6 +82,7 @@ export async function createRoom(data: RoomFormData) { data: { name: validatedData.name, createdBy: creatorId, + floorPlan: floorPlanColumn(validatedData.floorPlan), }, }); @@ -314,22 +329,90 @@ export async function updateRoom(roomId: string, data: RoomFormData) { } 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({ where: { id: roomId }, data: { 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/edit/${roomId}`); return { 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) { console.error("Error updating room:", error); diff --git a/src/modules/rooms/lib/floor-plan.ts b/src/modules/rooms/lib/floor-plan.ts new file mode 100644 index 0000000..f2333bb --- /dev/null +++ b/src/modules/rooms/lib/floor-plan.ts @@ -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(); + 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): 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): 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) { + 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, + pos: { x: number; y: number }, + room: Pick +): { 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 +): { 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); +} diff --git a/src/modules/rooms/schemas/room-schema.ts b/src/modules/rooms/schemas/room-schema.ts index 5fe749e..3832f54 100644 --- a/src/modules/rooms/schemas/room-schema.ts +++ b/src/modules/rooms/schemas/room-schema.ts @@ -1,7 +1,10 @@ import { z } from "zod"; +import { floorPlanSchema } from "../lib/floor-plan"; export const roomSchema = z.object({ 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; diff --git a/src/modules/rooms/types/room.types.ts b/src/modules/rooms/types/room.types.ts index d099287..7897f76 100644 --- a/src/modules/rooms/types/room.types.ts +++ b/src/modules/rooms/types/room.types.ts @@ -2,6 +2,8 @@ export type Room = { id: string; name: string; createdBy: string; + /** Rohwert aus der DB - über parseFloorPlan() lesen. */ + floorPlan?: unknown; createdAt: Date; updatedAt: Date; }; diff --git a/src/modules/rooms/ui/components/RoomsListItem.tsx b/src/modules/rooms/ui/components/RoomsListItem.tsx index 9146d06..5835c02 100644 --- a/src/modules/rooms/ui/components/RoomsListItem.tsx +++ b/src/modules/rooms/ui/components/RoomsListItem.tsx @@ -5,6 +5,7 @@ import { Pen, Trash2 } from "lucide-react"; */ interface RoomsListItemProps { roomName: string; // Name des Raumes + spotCount: number; // Buchbare Plätze laut Grundriss, 0 = kein Grundriss onEdit?: () => void; // Callback-Funktion beim Klick auf Bearbeiten-Button onDelete?: () => void; // Callback-Funktion beim Klick auf Löschen-Button showDeleteIcon?: boolean; // Ob das Löschen-Icon angezeigt werden soll @@ -12,6 +13,7 @@ interface RoomsListItemProps { const RoomsListItem: React.FC = ({ roomName, + spotCount, onEdit, onDelete, showDeleteIcon, @@ -21,6 +23,11 @@ const RoomsListItem: React.FC = ({

{roomName}

+

+ {spotCount === 0 + ? "Ohne Grundriss" + : `${spotCount} ${spotCount === 1 ? "Platz" : "Plätze"} im Grundriss`} +

{showDeleteIcon && ( diff --git a/src/modules/rooms/ui/components/floor-plan/FloorPlanEditor.tsx b/src/modules/rooms/ui/components/floor-plan/FloorPlanEditor.tsx new file mode 100644 index 0000000..0cfdd06 --- /dev/null +++ b/src/modules/rooms/ui/components/floor-plan/FloorPlanEditor.tsx @@ -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(null); + const [selectedId, setSelectedId] = useState(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) => + 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) => { + if (!selected) return; + const step = e.shiftKey ? 0.5 : SNAP_STEP; + const actions: Record 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 ( + +
+ {/* Raumgröße + Zähler */} +
+ setRoomSize({ width })} + /> + × + setRoomSize({ depth })} + /> + + {spotCount}{" "} + {spotCount === 1 ? "buchbarer Platz" : "buchbare Plätze"} + +
+ +
+ {/* Geräteleiste: am Handy eine wischbare Reihe, ab md eine Spalte */} +
+ Buchbar + {BOOKABLE_TYPES.map((type) => ( + addItem(type)} /> + ))} + Raum + {ROOM_TYPES.map((type) => ( + addItem(type)} /> + ))} +
+ +
+ {/* Auswahl-Leiste */} +
+ {selected ? ( + <> + + {EQUIPMENT[selected.type].label} + + {isBookable(selected) && ( + + )} + {labelTaken && Nummer gibt es schon} +
+ + + +
+ + ) : ( + + Gerät anklicken, um es zu drehen, zu nummerieren oder zu entfernen. + + )} +
+ + {/* Raum */} +
+ setSelectedId(null)} + > + {plan.items.length === 0 && ( +
+ Geräte hierher ziehen oder in der Leiste antippen +
+ )} + {plan.items.map((item) => ( + setSelectedId(item.id)} + /> + ))} +
+
+
+
+
+ + + {paletteDrag && ( +
+ +
+ )} +
+
+ ); +} + +function PaletteHeading({ children, className }: { children: string; className?: string }) { + return ( + + {children} + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} diff --git a/src/modules/rooms/ui/components/floor-plan/FloorPlanStage.tsx b/src/modules/rooms/ui/components/floor-plan/FloorPlanStage.tsx new file mode 100644 index 0000000..4cf84d3 --- /dev/null +++ b/src/modules/rooms/ui/components/floor-plan/FloorPlanStage.tsx @@ -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, 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, + plan: Pick +): 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; + children?: ReactNode; + /** Begrenzt die Höhe - ein tiefer, schmaler Raum soll nicht den ganzen Bildschirm füllen. */ + maxHeight?: string; + className?: string; + stageRef?: Ref; + onClick?: () => void; +} + +export function FloorPlanStage({ + plan, + children, + maxHeight = "60vh", + className, + stageRef, + onClick, +}: FloorPlanStageProps) { + const ratio = plan.width / plan.depth; + return ( +
+
+ {children} +
+
+ ); +} + +/** Gerät samt Platznummer, füllt seinen (per itemBoxStyle positionierten) Container. */ +export function EquipmentGraphic({ + item, + tone, + accent, +}: { + item: Pick; + tone: EquipmentTone; + accent?: { color: string; text: string }; +}) { + const colors = toneColors(tone, accent); + return ( + + + {isBookable(item) && item.label && ( + + + {item.label} + + + )} + + ); +} + +/** + * 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 ( + + + {DRAWINGS[type](W, H)} + + + ); +} + +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 ( + <> + + {/* Fußstange */} + + {/* Schlitten */} + + + ); +} + +const DRAWINGS: Record React.ReactNode> = { + reformer, + tower: (W, H) => ( + <> + {reformer(W, H)} + {/* Turm am Kopfende */} + + + ), + cadillac: (W, H) => ( + <> + + + {[ + [5, 5], + [W - 5, 5], + [5, H - 5], + [W - 5, H - 5], + ].map(([cx, cy]) => ( + + ))} + + ), + chair: (W, H) => ( + <> + + + {/* Pedal */} + + + ), + barrel: (W, H) => ( + <> + + {/* Sprossen */} + {[0.12, 0.24, 0.36].map((f) => ( + + ))} + {/* Fass */} + + + ), + mat: (W, H) => ( + <> + + {[0.25, 0.5, 0.75].map((f) => ( + + ))} + + ), + trainer: (W, H) => ( + <> + + + + + ), + door: (W, H) => ( + <> + + {/* Türblatt und Schwenkbereich - ragen bewusst in den Raum hinein */} + + + + ), + mirror: (W, H) => , +}; diff --git a/src/modules/rooms/ui/components/floor-plan/SpotPicker.tsx b/src/modules/rooms/ui/components/floor-plan/SpotPicker.tsx new file mode 100644 index 0000000..6479224 --- /dev/null +++ b/src/modules/rooms/ui/components/floor-plan/SpotPicker.tsx @@ -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 ( +
+ + {floorPlan.items.map((item) => { + if (!isBookable(item)) { + return ( +
+ +
+ ); + } + + 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 ( + + ); + })} +
+ +
+ Frei + Belegt + + {mySpotId ? "Ihr Platz" : "Ihre Wahl"} + +
+
+ ); +} + +function LegendSwatch({ + children, + className, + style, +}: { + children: string; + className?: string; + style?: React.CSSProperties; +}) { + return ( + + + {children} + + ); +} diff --git a/src/modules/rooms/ui/views/rooms-create-view.tsx b/src/modules/rooms/ui/views/rooms-create-view.tsx index 857ba4d..7834e23 100644 --- a/src/modules/rooms/ui/views/rooms-create-view.tsx +++ b/src/modules/rooms/ui/views/rooms-create-view.tsx @@ -6,9 +6,16 @@ import { Button } from "@/src/components/ui/button"; import { createRoom, updateRoom } from "../../actions/room-actions"; import { toast } from "sonner"; 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 { 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 { mode?: "create" | "edit"; @@ -19,6 +26,9 @@ interface RoomsCreateViewProps { const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateViewProps = {}) => { const router = useRouter(); const [roomName, setRoomName] = useState(mode === "edit" ? initialData?.name || "" : ""); + const [floorPlan, setFloorPlan] = useState( + () => parseFloorPlan(initialData?.floorPlan) ?? { ...DEFAULT_ROOM_SIZE, items: [] } + ); const [errors, setErrors] = useState>({}); 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 (floorPlan.items.length > 0) { + const plan = floorPlanSchema.safeParse(floorPlan); + if (!plan.success) + newErrors.floorPlan = plan.error.issues[0]?.message ?? "Ungültiger Grundriss"; + } + setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = async () => { if (!validateForm()) { - toast.error("Bitte füllen Sie alle erforderlichen Felder aus"); + toast.error("Bitte prüfen Sie Ihre Eingaben"); return; } @@ -54,6 +70,7 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi try { const roomData = { name: roomName, + floorPlan: floorPlan.items.length > 0 ? floorPlan : null, }; if (mode === "edit" && !roomId) { @@ -95,7 +112,7 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi

{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"}

@@ -105,8 +122,8 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi )}
-
-
+
+
{errors.name &&

{errors.name}

}
-
+ +
+
+
+ +
+
+

Grundriss (optional)

+

+ 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. +

+
+
+ + {errors.floorPlan &&

{errors.floorPlan}

} +
+ +