mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
Merge pull request #171 from Since-3/feature/added-floor-plan
added floor plan to rooms
This commit is contained in:
commit
55f0163af5
34 changed files with 3004 additions and 117 deletions
|
|
@ -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;
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "CourseSpotHold" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'checkout',
|
||||
ADD COLUMN "stripeSessionId" TEXT;
|
||||
|
|
@ -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:<id>" oder "guest:<email>": pro Person und Kurs läuft höchstens
|
||||
// ein offener Checkout - wer zurück geht und neu wählt, beendet den alten (siehe
|
||||
// Checkout-Route). Holds abgeschlossener Checkouts bleiben dabei unangetastet.
|
||||
model CourseSpotHold {
|
||||
id String @id @default(uuid())
|
||||
courseId String
|
||||
spotId String
|
||||
holderKey String
|
||||
expiresAt DateTime
|
||||
stripeSessionId String? // Checkout-Session, für die der Platz gehalten wird. null, solange sie angelegt wird.
|
||||
status String @default("checkout") // "checkout" (Session offen) | "awaiting_payment" (Checkout abgeschlossen, SEPA-Zahlung unterwegs)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([courseId, spotId])
|
||||
@@index([courseId])
|
||||
}
|
||||
|
||||
// Bewusst nur für registrierte User (kein guestEmail-Feld): ein
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<CapacityOutcome> {
|
||||
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<T>(promise: Promise<T>, ms: number): Promise<T | null
|
|||
async function sendBookingConfirmation(
|
||||
recipientEmail: string,
|
||||
courseId: string,
|
||||
amountPaidCents: number
|
||||
amountPaidCents: number,
|
||||
spot: AssignedSpot | null
|
||||
) {
|
||||
try {
|
||||
const course = await prisma.course.findUnique({
|
||||
|
|
@ -828,6 +905,8 @@ async function sendBookingConfirmation(
|
|||
businessName: course.business.name,
|
||||
businessAddress: course.business.address,
|
||||
amountPaidCents,
|
||||
spotLabel: spot?.spotLabel ?? null,
|
||||
spotChanged: spot?.changed ?? false,
|
||||
myCoursesUrl: `${baseUrl}/courses/myCourses`,
|
||||
coverImageUrl: course.coverImage,
|
||||
companyName: process.env.COMPANY_NAME,
|
||||
|
|
@ -966,6 +1045,14 @@ async function handleCheckoutUnpaid(session: Stripe.Checkout.Session) {
|
|||
const courseId = session.metadata?.courseId;
|
||||
const isGuest = session.metadata?.isGuest === "true";
|
||||
const discountReservationToken = session.metadata?.discountReservationToken || null;
|
||||
const spotHoldId = session.metadata?.spotHoldId || null;
|
||||
|
||||
// Nie bezahlt - der Platz wird wieder frei.
|
||||
if (spotHoldId) {
|
||||
await releaseSpotHold(prisma, spotHoldId).catch((error) =>
|
||||
console.error(`Platz-Hold ${spotHoldId} konnte nicht freigegeben werden:`, error)
|
||||
);
|
||||
}
|
||||
|
||||
// Gibt einen beim Checkout-Start reservierten Rabattcode-Slot wieder frei
|
||||
// (siehe reserveDiscountCode), da diese Session nie bezahlt wurde. Läuft
|
||||
|
|
|
|||
|
|
@ -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 }:
|
|||
<span>{course.business.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{result.success && result.spotLabel && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LayoutGrid size={15} className="text-gray-400 shrink-0" />
|
||||
<span>
|
||||
Ihr Platz: <b>{result.spotLabel}</b>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 mt-4 pt-3">
|
||||
|
|
|
|||
57
src/app/floor-plan-preview/page.tsx
Normal file
57
src/app/floor-plan-preview/page.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
// TEMPORÄR - nur für die visuelle Prüfung, wird wieder gelöscht.
|
||||
import { useState } from "react";
|
||||
import FloorPlanEditor from "@/src/modules/rooms/ui/components/floor-plan/FloorPlanEditor";
|
||||
import { SpotPicker } from "@/src/modules/rooms/ui/components/floor-plan/SpotPicker";
|
||||
import type { FloorPlan, FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
|
||||
|
||||
const r = (n: number, x: number, y: number): FloorPlanItem => ({
|
||||
id: `r${n}`,
|
||||
type: "reformer",
|
||||
x,
|
||||
y,
|
||||
rotation: 0,
|
||||
label: String(n),
|
||||
});
|
||||
const initial: FloorPlan = {
|
||||
width: 8,
|
||||
depth: 6,
|
||||
items: [
|
||||
{ id: "mirror", type: "mirror", x: 4, y: 0.06, rotation: 0, label: "" },
|
||||
{ id: "trainer", type: "trainer", x: 4, y: 0.7, rotation: 0, label: "" },
|
||||
{ id: "door", type: "door", x: 7.2, y: 5.9, rotation: 180, label: "" },
|
||||
r(1, 1.0, 2.5),
|
||||
r(2, 2.0, 2.5),
|
||||
r(3, 3.0, 2.5),
|
||||
r(4, 4.0, 2.5),
|
||||
r(5, 5.0, 2.5),
|
||||
r(6, 6.0, 2.5),
|
||||
{ id: "c1", type: "chair", x: 7.4, y: 1.5, rotation: 90, label: "1" },
|
||||
{ id: "m1", type: "mat", x: 1.5, y: 5.0, rotation: 90, label: "1" },
|
||||
{ id: "b1", type: "barrel", x: 4.5, y: 5.0, rotation: 90, label: "1" },
|
||||
{ id: "t1", type: "tower", x: 7.4, y: 3.8, rotation: 0, label: "1" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [plan, setPlan] = useState(initial);
|
||||
const [sel, setSel] = useState<string | null>("r3");
|
||||
return (
|
||||
<div className="p-6 flex flex-col gap-10 max-w-5xl mx-auto">
|
||||
<section className="border border-white/60 bg-white/60 rounded-2xl p-5">
|
||||
<h2 className="font-semibold mb-4">Editor</h2>
|
||||
<FloorPlanEditor value={plan} onChange={setPlan} />
|
||||
</section>
|
||||
<section className="border rounded-2xl p-5 bg-white max-w-3xl">
|
||||
<h2 className="font-semibold mb-4">Platzwahl</h2>
|
||||
<SpotPicker
|
||||
floorPlan={plan}
|
||||
takenSpotIds={["r1", "r5", "c1"]}
|
||||
selectedSpotId={sel}
|
||||
onSelect={setSel}
|
||||
accentColor="#0f766e"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}<br />${safeTimeFrom} – ${safeTimeTo} Uhr` },
|
||||
...(safeBusinessAddress ? [{ label: "Ort", valueHtml: safeBusinessAddress }] : []),
|
||||
...(safeSpotLabel
|
||||
? [
|
||||
{
|
||||
label: "Ihr Platz",
|
||||
valueHtml: `<strong>${safeSpotLabel}</strong>${
|
||||
spotChangedNote
|
||||
? `<br /><span style="color: #666666; font-size: 13px;">${spotChangedNote}</span>`
|
||||
: ""
|
||||
}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
})}
|
||||
|
||||
|
|
@ -138,7 +160,7 @@ vielen Dank für Ihre Buchung. Ihre Zahlung wurde erfolgreich verarbeitet.
|
|||
Kurs: ${courseName}
|
||||
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!
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; pbSrc?: string }>;
|
||||
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<CourseSpotMap | null> {
|
||||
try {
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { id: courseId, status: "published", business: { isPublic: true } },
|
||||
select: { spotBooking: true, room: true },
|
||||
});
|
||||
if (!course?.spotBooking || !course.room) return null;
|
||||
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: course.room },
|
||||
select: { floorPlan: true },
|
||||
});
|
||||
const floorPlan = parseFloorPlan(room?.floorPlan);
|
||||
if (!floorPlan || bookableSpots(floorPlan).length === 0) return null;
|
||||
|
||||
const userData = await getUserData();
|
||||
const userId = userData && isUser(userData) ? userData.id : null;
|
||||
|
||||
const [booked, held, myBooking] = await Promise.all([
|
||||
getBookedSpotIds(prisma, courseId),
|
||||
getHeldSpotIds(
|
||||
prisma,
|
||||
courseId,
|
||||
userId ? { exceptHolderKey: spotHolderKey({ userId }) } : {}
|
||||
),
|
||||
userId
|
||||
? prisma.courseBooking.findFirst({
|
||||
where: { courseId, userId, paymentStatus: "paid" },
|
||||
select: { spotId: true },
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
const mySpotId = myBooking?.spotId ?? null;
|
||||
const takenSpotIds = [...new Set([...booked, ...held])].filter((id) => id !== mySpotId);
|
||||
return { floorPlan, takenSpotIds, mySpotId };
|
||||
} catch (error) {
|
||||
console.error("Error fetching course spot map:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBusinessBySlug(slug: string) {
|
||||
const fetch = unstable_cache(
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
226
src/modules/courses/lib/course-spots.ts
Normal file
226
src/modules/courses/lib/course-spots.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import type { prisma as prismaClient } from "@/src/lib/prisma";
|
||||
import {
|
||||
bookableSpots,
|
||||
parseFloorPlan,
|
||||
spotDisplayName,
|
||||
type FloorPlanItem,
|
||||
} from "@/src/modules/rooms/lib/floor-plan";
|
||||
|
||||
/**
|
||||
* Platzwahl bei Kursen (z.B. Reformer im Pilates-Studio).
|
||||
*
|
||||
* Ablauf:
|
||||
* 1. Checkout-Start: holdSpot() hält den gewählten Platz für die Dauer der
|
||||
* Stripe-Session frei (CourseSpotHold), damit kein zweiter Kunde mit
|
||||
* demselben Platz zur Kasse geht. Startet dieselbe Person erneut, beendet
|
||||
* die Checkout-Route vorher ihre alte, noch offene Session.
|
||||
* 2. Webhook nach der Zahlung: assignSpot() schreibt den Platz an die Buchung.
|
||||
* Verbindlich ist nur, was bezahlt ist - war der Wunschplatz inzwischen
|
||||
* doch vergeben (Hold abgelaufen, SEPA-Zahlung kam Tage später), bekommt
|
||||
* der Kunde den nächsten freien Platz statt einer Erstattung.
|
||||
* 3. Danach oder bei abgelaufener Session: releaseSpotHold().
|
||||
*
|
||||
* Belegt ist ein Platz durch eine "paid"-Buchung mit dieser spotId - dieselbe
|
||||
* Definition wie bei der Teilnehmerzahl. Stornos geben den Platz damit von
|
||||
* selbst frei, ohne dass jemand spotId zurücksetzen muss.
|
||||
*/
|
||||
|
||||
type PrismaOrTx =
|
||||
| typeof prismaClient
|
||||
| Parameters<Parameters<typeof prismaClient.$transaction>[0]>[0];
|
||||
|
||||
/** Stripe erlaubt für expires_at frühestens 30 Minuten - so lange hält der Platz. */
|
||||
export const SPOT_HOLD_MINUTES = 30;
|
||||
|
||||
/**
|
||||
* Nach einem abgeschlossenen, aber noch nicht bezahlten Checkout (SEPA) bleibt
|
||||
* der Platz so lange reserviert, bis die Zahlung ankommt oder scheitert.
|
||||
*/
|
||||
export const DELAYED_PAYMENT_HOLD_DAYS = 14;
|
||||
|
||||
export function spotHolderKey(params: { userId?: string | null; guestEmail?: string | null }) {
|
||||
if (params.userId) return `user:${params.userId}`;
|
||||
return `guest:${(params.guestEmail ?? "").trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die buchbaren Plätze eines Kurses, oder null, wenn ohne Platzwahl gebucht
|
||||
* wird: Platzwahl ist aus, kein Raum, oder der Raum hat (noch) keine Geräte.
|
||||
*/
|
||||
export async function getCourseSpots(
|
||||
db: PrismaOrTx,
|
||||
course: { spotBooking?: boolean | null; room?: string | null }
|
||||
): Promise<FloorPlanItem[] | null> {
|
||||
if (!course.spotBooking || !course.room) return null;
|
||||
const room = await db.room.findUnique({
|
||||
where: { id: course.room },
|
||||
select: { floorPlan: true },
|
||||
});
|
||||
const spots = bookableSpots(parseFloorPlan(room?.floorPlan));
|
||||
return spots.length > 0 ? spots : null;
|
||||
}
|
||||
|
||||
/** Plätze mit bezahlter Buchung. */
|
||||
export async function getBookedSpotIds(db: PrismaOrTx, courseId: string): Promise<Set<string>> {
|
||||
const rows = await db.courseBooking.findMany({
|
||||
where: { courseId, paymentStatus: "paid", spotId: { not: null } },
|
||||
select: { spotId: true },
|
||||
});
|
||||
return new Set(rows.map((r) => r.spotId!));
|
||||
}
|
||||
|
||||
/**
|
||||
* Plätze, die gerade jemand im Checkout hält. exceptHolderKey blendet die
|
||||
* eigenen offenen Checkouts aus - wer den Checkout abbricht und zurückkommt,
|
||||
* soll seinen Platz nicht als belegt sehen (der nächste Checkout ersetzt ihn).
|
||||
* Ein eigener abgeschlossener Checkout (SEPA unterwegs) bleibt belegt.
|
||||
*/
|
||||
export async function getHeldSpotIds(
|
||||
db: PrismaOrTx,
|
||||
courseId: string,
|
||||
opts: { exceptHolderKey?: string; exceptHoldId?: string } = {}
|
||||
): Promise<Set<string>> {
|
||||
const rows = await db.courseSpotHold.findMany({
|
||||
where: {
|
||||
courseId,
|
||||
expiresAt: { gt: new Date() },
|
||||
...(opts.exceptHolderKey
|
||||
? { NOT: { holderKey: opts.exceptHolderKey, status: "checkout" } }
|
||||
: {}),
|
||||
...(opts.exceptHoldId ? { id: { not: opts.exceptHoldId } } : {}),
|
||||
},
|
||||
select: { spotId: true },
|
||||
});
|
||||
return new Set(rows.map((r) => r.spotId));
|
||||
}
|
||||
|
||||
export type HoldSpotResult = { held: true; holdId: string } | { held: false; reason: string };
|
||||
|
||||
/**
|
||||
* Hält einen Platz für den Checkout frei. Die Eindeutigkeit erzwingt
|
||||
* @@unique([courseId, spotId]): fordern zwei Kunden gleichzeitig denselben
|
||||
* Platz an, gewinnt der erste Insert, der zweite bekommt P2002.
|
||||
*
|
||||
* Räumt nur abgelaufene Holds weg. Holds derselben Person aus früheren
|
||||
* Checkouts gibt die Checkout-Route vorher frei, und zwar nur, wenn deren
|
||||
* Session nachweislich noch offen war - ein bereits bezahlter oder per SEPA
|
||||
* abgeschlossener Checkout behält seinen Platz.
|
||||
*/
|
||||
export async function holdSpot(
|
||||
db: typeof prismaClient,
|
||||
params: { courseId: string; spotId: string; holderKey: string; expiresAt: Date }
|
||||
): Promise<HoldSpotResult> {
|
||||
const { courseId, spotId, holderKey, expiresAt } = params;
|
||||
|
||||
// Abgelaufene Holds wegräumen - sonst blockiert ihr Unique-Key den Platz.
|
||||
await db.courseSpotHold.deleteMany({
|
||||
where: { courseId, expiresAt: { lte: new Date() } },
|
||||
});
|
||||
|
||||
const booked = await db.courseBooking.findFirst({
|
||||
where: { courseId, spotId, paymentStatus: "paid" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (booked) {
|
||||
return {
|
||||
held: false,
|
||||
reason: "Dieser Platz ist bereits vergeben. Bitte wählen Sie einen anderen.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const hold = await db.courseSpotHold.create({
|
||||
data: { courseId, spotId, holderKey, expiresAt },
|
||||
select: { id: true },
|
||||
});
|
||||
return { held: true, holdId: hold.id };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && (error as { code: string }).code === "P2002") {
|
||||
const existing = await db.courseSpotHold.findUnique({
|
||||
where: { courseId_spotId: { courseId, spotId } },
|
||||
select: { holderKey: true },
|
||||
});
|
||||
return {
|
||||
held: false,
|
||||
reason:
|
||||
existing?.holderKey === holderKey
|
||||
? "Für diesen Platz läuft bereits Ihre Zahlung. Bitte warten Sie die Bestätigung ab."
|
||||
: "Dieser Platz wird gerade von jemand anderem gebucht. Bitte wählen Sie einen anderen.",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Offene Checkouts einer Person für diesen Kurs - die Checkout-Route ersetzt sie. */
|
||||
export async function getOpenCheckoutHolds(
|
||||
db: PrismaOrTx,
|
||||
courseId: string,
|
||||
holderKey: string
|
||||
): Promise<{ id: string; stripeSessionId: string | null }[]> {
|
||||
return db.courseSpotHold.findMany({
|
||||
where: { courseId, holderKey, status: "checkout" },
|
||||
select: { id: true, stripeSessionId: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Verknüpft den Hold mit seiner Session, sobald Stripe sie angelegt hat. */
|
||||
export async function attachSessionToHold(
|
||||
db: PrismaOrTx,
|
||||
holdId: string,
|
||||
stripeSessionId: string
|
||||
): Promise<void> {
|
||||
await db.courseSpotHold.updateMany({ where: { id: holdId }, data: { stripeSessionId } });
|
||||
}
|
||||
|
||||
export async function releaseSpotHold(db: PrismaOrTx, holdId: string): Promise<void> {
|
||||
await db.courseSpotHold.deleteMany({ where: { id: holdId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout abgeschlossen, Zahlung unterwegs (SEPA): Platz länger halten und
|
||||
* als abgeschlossen markieren, damit ein neuer Checkout derselben Person ihn
|
||||
* nicht als "alten offenen Checkout" freigibt.
|
||||
*/
|
||||
export async function extendSpotHold(db: PrismaOrTx, holdId: string, until: Date): Promise<void> {
|
||||
await db.courseSpotHold.updateMany({
|
||||
where: { id: holdId },
|
||||
data: { expiresAt: until, status: "awaiting_payment" },
|
||||
});
|
||||
}
|
||||
|
||||
export type AssignedSpot = { spotId: string; spotLabel: string; changed: boolean };
|
||||
|
||||
/**
|
||||
* Bestimmt im Webhook (innerhalb der Kapazitäts-Transaktion) den Platz einer
|
||||
* bezahlten Buchung. Wunschplatz, wenn er noch frei ist - sonst der erste freie
|
||||
* Platz, den niemand gerade im Checkout hält. null = kein Platz mehr frei, die
|
||||
* Buchung ist dann wie bei einem vollen Kurs abzulehnen.
|
||||
*/
|
||||
export async function assignSpot(
|
||||
tx: PrismaOrTx,
|
||||
params: {
|
||||
courseId: string;
|
||||
spots: FloorPlanItem[];
|
||||
requestedSpotId: string | null;
|
||||
holdId: string | null;
|
||||
}
|
||||
): Promise<AssignedSpot | null> {
|
||||
const { courseId, spots, requestedSpotId, holdId } = params;
|
||||
|
||||
const booked = await getBookedSpotIds(tx, courseId);
|
||||
const free = spots.filter((s) => !booked.has(s.id));
|
||||
if (free.length === 0) return null;
|
||||
|
||||
let chosen = requestedSpotId ? free.find((s) => s.id === requestedSpotId) : undefined;
|
||||
if (!chosen) {
|
||||
const held = await getHeldSpotIds(tx, courseId, holdId ? { exceptHoldId: holdId } : {});
|
||||
chosen = free.find((s) => !held.has(s.id)) ?? free[0];
|
||||
}
|
||||
|
||||
return {
|
||||
spotId: chosen.id,
|
||||
spotLabel: spotDisplayName(chosen),
|
||||
changed: requestedSpotId !== null && chosen.id !== requestedSpotId,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export type Course = {
|
|||
createdBy: string;
|
||||
businessId: string | null;
|
||||
coverImage: string | null;
|
||||
spotBooking: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<CourseSpotMap | null>(null);
|
||||
const [isLoadingSpots, setIsLoadingSpots] = useState(false);
|
||||
const [selectedSpotId, setSelectedSpotId] = useState<string | null>(null);
|
||||
const [highlightPicker, setHighlightPicker] = useState(false);
|
||||
const spotPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isAdminView = paymentStatus !== undefined;
|
||||
|
||||
|
|
@ -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<string, string> = { 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 = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{isAdminView && course.spotLabel && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LayoutGrid size={15} className="text-gray-400 shrink-0" />
|
||||
<span>
|
||||
<b>Platz:</b> {course.spotLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{business?.address && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MapPin size={15} className="text-gray-400 shrink-0" />
|
||||
|
|
@ -442,6 +532,40 @@ export const BookingDialog = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{spotMode && (isBooked ? mySpot : !isFull) && (
|
||||
<div
|
||||
ref={spotPickerRef}
|
||||
className={`flex flex-col gap-3 rounded-xl border p-3 sm:p-4 transition-colors ${
|
||||
highlightPicker ? "border-gray-900 bg-gray-50" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<LayoutGrid size={15} className="text-gray-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
{isBooked ? (
|
||||
<b>Ihr Platz: {spotDisplayName(mySpot!)}</b>
|
||||
) : (
|
||||
<>
|
||||
<b>Platz wählen</b>
|
||||
<p className="text-gray-500 mt-0.5">
|
||||
Tippen Sie auf ein freies Gerät – es ist für Sie reserviert, sobald die
|
||||
Zahlung durch ist.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SpotPicker
|
||||
floorPlan={spotMap.floorPlan}
|
||||
takenSpotIds={spotMap.takenSpotIds}
|
||||
selectedSpotId={selectedSpotId}
|
||||
mySpotId={spotMap.mySpotId}
|
||||
onSelect={isBooked ? undefined : setSelectedSpotId}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{course.description && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Info size={15} className="text-gray-400 shrink-0 mt-0.5" />
|
||||
|
|
@ -505,6 +629,17 @@ export const BookingDialog = ({
|
|||
Kurs + {formatEuro(serviceFee)} Servicegebühr
|
||||
</span>
|
||||
)}
|
||||
{spotMode && !isBooked && !isFull && (
|
||||
<span className="text-xs text-gray-700 mt-0.5">
|
||||
{selectedSpot ? (
|
||||
<>
|
||||
Ihr Platz: <b>{spotDisplayName(selectedSpot)}</b>
|
||||
</>
|
||||
) : (
|
||||
"Noch kein Platz gewählt"
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
|
||||
|
|
@ -543,7 +678,7 @@ export const BookingDialog = ({
|
|||
>
|
||||
Schließen
|
||||
</Button>
|
||||
{isCheckingStatus ? (
|
||||
{isCheckingStatus || isLoadingSpots ? (
|
||||
<Button disabled className="flex-1 sm:flex-initial">
|
||||
Lade…
|
||||
</Button>
|
||||
|
|
@ -570,12 +705,12 @@ export const BookingDialog = ({
|
|||
// Gäste buchen über das Formular darunter - ein zweiter
|
||||
// "Buchen"-Button daneben wäre nur verwirrend.
|
||||
<Button
|
||||
onClick={() => handleBooking()}
|
||||
onClick={() => (needsSpot ? showSpotPicker() : handleBooking())}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 sm:flex-initial"
|
||||
style={accentButtonStyle}
|
||||
>
|
||||
{isSubmitting ? "Wird gebucht…" : "Buchen"}
|
||||
{isSubmitting ? "Wird gebucht…" : needsSpot ? "Platz wählen" : "Buchen"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -629,12 +764,16 @@ export const BookingDialog = ({
|
|||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleBooking({ asGuest: true })}
|
||||
disabled={isSubmitting || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
|
||||
onClick={() => (needsSpot ? showSpotPicker() : handleBooking({ asGuest: true }))}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
isLoadingSpots ||
|
||||
(!needsSpot && !EMAIL_RE.test(guestEmail.trim().toLowerCase()))
|
||||
}
|
||||
className="w-full"
|
||||
style={accentButtonStyle}
|
||||
>
|
||||
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
|
||||
{isSubmitting ? "Wird gebucht…" : needsSpot ? "Platz wählen" : "Als Gast buchen"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{dateFormatted}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="flex items-center gap-1 min-w-0">
|
||||
<MapPin size={13} className="shrink-0" />
|
||||
{booking.roomName}
|
||||
<span className="truncate">
|
||||
{booking.roomName}
|
||||
{booking.spotLabel && ` · ${booking.spotLabel}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [rooms, setRooms] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [rooms, setRooms] = useState<Array<{ value: string; label: string; spotCount: number }>>(
|
||||
[]
|
||||
);
|
||||
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
||||
const [trainers, setTrainers] = useState<Array<{ value: string; label: string }>>([]);
|
||||
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 = ({
|
|||
<GenericDropdown
|
||||
label="Raum"
|
||||
selected={selectedRoom}
|
||||
onSelect={setSelectedRoom}
|
||||
onSelect={handleRoomSelect}
|
||||
options={rooms}
|
||||
error={errors.room}
|
||||
disabled={isLoadingRooms}
|
||||
|
|
@ -772,6 +800,42 @@ const CourseCreateView = ({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
{roomSpotCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = !spotBooking;
|
||||
setSpotBooking(next);
|
||||
if (next) fitParticipantsToSpots(roomSpotCount);
|
||||
}}
|
||||
aria-pressed={spotBooking}
|
||||
className={`flex items-center gap-3 w-full rounded-xl border p-4 text-left transition-all cursor-pointer ${
|
||||
spotBooking
|
||||
? "border-[#c9a84c]/50 bg-[#c9a84c]/5"
|
||||
: "border-gray-200 bg-white/40 hover:border-gray-300 hover:bg-white/60"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-center size-8 rounded-full border-2 transition-all shrink-0 ${
|
||||
spotBooking ? "border-[#c9a84c] bg-[#c9a84c]" : "border-gray-300 bg-white"
|
||||
}`}
|
||||
>
|
||||
{spotBooking && <Check className="size-4 text-white" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">Platzwahl beim Buchen</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Kunden wählen ihr Gerät im Grundriss – {roomSpotCount}{" "}
|
||||
{roomSpotCount === 1 ? "Platz" : "Plätze"} in diesem Raum
|
||||
</p>
|
||||
</div>
|
||||
<LayoutGrid
|
||||
className={`ml-auto size-4 shrink-0 transition-colors ${
|
||||
spotBooking ? "text-[#c9a84c]" : "text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<InputComponent
|
||||
|
|
@ -818,8 +882,14 @@ const CourseCreateView = ({
|
|||
value={maxParticipants}
|
||||
onChange={(e) => setMaxParticipants(e.target.value)}
|
||||
/>
|
||||
{errors.maxParticipants && (
|
||||
{errors.maxParticipants ? (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.maxParticipants}</p>
|
||||
) : (
|
||||
spotBookingActive && (
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
Höchstens {roomSpotCount} – so viele Plätze hat der Raum
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export type KundeBooking = {
|
|||
trainers: string[];
|
||||
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{dateFormatted}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="flex items-center gap-1 min-w-0">
|
||||
<MapPin size={13} className="shrink-0" />
|
||||
{booking.roomName}
|
||||
<span className="truncate">
|
||||
{booking.roomName}
|
||||
{booking.spotLabel && ` · ${booking.spotLabel}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
184
src/modules/rooms/lib/floor-plan.ts
Normal file
184
src/modules/rooms/lib/floor-plan.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Grundriss eines Raums (Room.floorPlan).
|
||||
*
|
||||
* Koordinaten in Metern, Ursprung oben links. x/y ist der MITTELPUNKT eines
|
||||
* Geräts - so bleibt die Position beim Drehen gleich. Die Maße im Katalog gelten
|
||||
* für rotation 0 (w entlang x, h entlang y); bei 90/270 tauschen sie.
|
||||
*
|
||||
* Buchbare Geräte (Reformer, Matte, ...) sind die Plätze, die Kunden bei Kursen
|
||||
* mit Platzwahl auswählen. Ihre id ist stabil und landet als CourseBooking.spotId
|
||||
* an der Buchung - deshalb darf der Editor ids nie neu vergeben, nur neue erzeugen.
|
||||
*/
|
||||
|
||||
export const EQUIPMENT = {
|
||||
reformer: { label: "Reformer", w: 0.7, h: 2.4, bookable: true },
|
||||
tower: { label: "Tower", w: 0.75, h: 2.4, bookable: true },
|
||||
cadillac: { label: "Cadillac", w: 0.8, h: 2.2, bookable: true },
|
||||
chair: { label: "Chair", w: 0.65, h: 0.75, bookable: true },
|
||||
barrel: { label: "Barrel", w: 0.75, h: 1.2, bookable: true },
|
||||
mat: { label: "Matte", w: 0.65, h: 1.85, bookable: true },
|
||||
trainer: { label: "Trainer", w: 0.8, h: 0.8, bookable: false },
|
||||
door: { label: "Tür", w: 1.0, h: 0.2, bookable: false },
|
||||
mirror: { label: "Spiegel", w: 2.0, h: 0.12, bookable: false },
|
||||
} as const;
|
||||
|
||||
export type EquipmentType = keyof typeof EQUIPMENT;
|
||||
export const EQUIPMENT_TYPES = Object.keys(EQUIPMENT) as EquipmentType[];
|
||||
|
||||
export const ROTATIONS = [0, 90, 180, 270] as const;
|
||||
export type Rotation = (typeof ROTATIONS)[number];
|
||||
|
||||
export type FloorPlanItem = {
|
||||
id: string;
|
||||
type: EquipmentType;
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: Rotation;
|
||||
/** Nummer des Platzes innerhalb seines Gerätetyps ("3" → "Reformer 3"). Leer bei Deko. */
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type FloorPlan = {
|
||||
width: number;
|
||||
depth: number;
|
||||
items: FloorPlanItem[];
|
||||
};
|
||||
|
||||
export const ROOM_SIZE_MIN = 2;
|
||||
export const ROOM_SIZE_MAX = 40;
|
||||
export const DEFAULT_ROOM_SIZE = { width: 8, depth: 6 };
|
||||
const MAX_ITEMS = 120;
|
||||
|
||||
/** Positionsraster im Editor: 10 cm. */
|
||||
export const SNAP_STEP = 0.1;
|
||||
|
||||
const floorPlanItemSchema = z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
type: z.enum(EQUIPMENT_TYPES as [EquipmentType, ...EquipmentType[]]),
|
||||
x: z.number().finite(),
|
||||
y: z.number().finite(),
|
||||
rotation: z.union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)]),
|
||||
label: z.string().trim().max(12, "Platznummer darf höchstens 12 Zeichen haben"),
|
||||
});
|
||||
|
||||
export const floorPlanSchema = z
|
||||
.object({
|
||||
width: z.number().min(ROOM_SIZE_MIN).max(ROOM_SIZE_MAX),
|
||||
depth: z.number().min(ROOM_SIZE_MIN).max(ROOM_SIZE_MAX),
|
||||
items: z.array(floorPlanItemSchema).max(MAX_ITEMS, `Höchstens ${MAX_ITEMS} Geräte pro Raum`),
|
||||
})
|
||||
.refine((plan) => new Set(plan.items.map((i) => i.id)).size === plan.items.length, {
|
||||
message: "Doppelte Geräte-IDs im Grundriss",
|
||||
})
|
||||
.refine(
|
||||
(plan) => {
|
||||
const seen = new Set<string>();
|
||||
for (const item of plan.items) {
|
||||
if (!EQUIPMENT[item.type].bookable) continue;
|
||||
if (!item.label) return false;
|
||||
const key = `${item.type}:${item.label.toLowerCase()}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "Jeder Platz braucht eine eigene Nummer pro Gerätetyp" }
|
||||
);
|
||||
|
||||
/**
|
||||
* Liest Room.floorPlan aus der Datenbank. Liefert null für Räume ohne
|
||||
* Grundriss und für alles, was nicht dem Format entspricht - ein kaputter
|
||||
* Grundriss darf eine Buchung nie blockieren, sie läuft dann ohne Platzwahl.
|
||||
*/
|
||||
export function parseFloorPlan(value: unknown): FloorPlan | null {
|
||||
if (!value) return null;
|
||||
const parsed = floorPlanSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function isBookable(item: Pick<FloorPlanItem, "type">): boolean {
|
||||
return EQUIPMENT[item.type].bookable;
|
||||
}
|
||||
|
||||
/** Buchbare Plätze in fester Reihenfolge: Katalog-Reihenfolge, dann Nummer. */
|
||||
export function bookableSpots(plan: FloorPlan | null): FloorPlanItem[] {
|
||||
if (!plan) return [];
|
||||
return plan.items.filter(isBookable).sort((a, b) => {
|
||||
const byType = EQUIPMENT_TYPES.indexOf(a.type) - EQUIPMENT_TYPES.indexOf(b.type);
|
||||
if (byType !== 0) return byType;
|
||||
return a.label.localeCompare(b.label, "de", { numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** "Reformer 3" - so heißt der Platz in Dialog, E-Mail und Buchungsübersicht. */
|
||||
export function spotDisplayName(item: Pick<FloorPlanItem, "type" | "label">): string {
|
||||
const name = EQUIPMENT[item.type].label;
|
||||
return item.label ? `${name} ${item.label}` : name;
|
||||
}
|
||||
|
||||
/** Ausdehnung auf dem Boden, abhängig von der Drehung. */
|
||||
export function itemFootprint(item: Pick<FloorPlanItem, "type" | "rotation">) {
|
||||
const { w, h } = EQUIPMENT[item.type];
|
||||
return item.rotation % 180 === 0 ? { w, h } : { w: h, h: w };
|
||||
}
|
||||
|
||||
/** Kleinste freie Nummer für einen Gerätetyp - "1", "2", ... */
|
||||
export function nextLabel(items: FloorPlanItem[], type: EquipmentType): string {
|
||||
if (!EQUIPMENT[type].bookable) return "";
|
||||
const used = new Set(items.filter((i) => i.type === type).map((i) => i.label));
|
||||
let n = 1;
|
||||
while (used.has(String(n))) n++;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function snap(value: number): number {
|
||||
return Math.round(value / SNAP_STEP) * SNAP_STEP;
|
||||
}
|
||||
|
||||
/** Hält ein Gerät vollständig innerhalb des Raums und rastet es ein. */
|
||||
export function placeInRoom(
|
||||
item: Pick<FloorPlanItem, "type" | "rotation">,
|
||||
pos: { x: number; y: number },
|
||||
room: Pick<FloorPlan, "width" | "depth">
|
||||
): { x: number; y: number } {
|
||||
const { w, h } = itemFootprint(item);
|
||||
const clamp = (v: number, size: number, extent: number) =>
|
||||
Math.min(Math.max(v, extent / 2), Math.max(extent / 2, size - extent / 2));
|
||||
// Auf zwei Nachkommastellen runden, damit 0.1-Schritte nicht als 0.30000000000000004 landen.
|
||||
const round = (v: number) => Math.round(v * 100) / 100;
|
||||
return {
|
||||
x: round(clamp(snap(pos.x), room.width, w)),
|
||||
y: round(clamp(snap(pos.y), room.depth, h)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erste Stelle von oben links, an der ein neues Gerät nichts überlappt - damit
|
||||
* "Hinzufügen" per Klick die Geräte nebeneinander reiht statt sie zu stapeln.
|
||||
* Ist der Raum voll, landet es in der Mitte.
|
||||
*/
|
||||
export function findFreePosition(
|
||||
plan: FloorPlan,
|
||||
item: Pick<FloorPlanItem, "type" | "rotation">
|
||||
): { x: number; y: number } {
|
||||
const { w, h } = itemFootprint(item);
|
||||
const gap = 0.3;
|
||||
const overlaps = (x: number, y: number) =>
|
||||
plan.items.some((other) => {
|
||||
const o = itemFootprint(other);
|
||||
return (
|
||||
Math.abs(other.x - x) < (o.w + w) / 2 + gap / 2 &&
|
||||
Math.abs(other.y - y) < (o.h + h) / 2 + gap / 2
|
||||
);
|
||||
});
|
||||
|
||||
for (let y = h / 2 + gap; y + h / 2 <= plan.depth; y += h + gap) {
|
||||
for (let x = w / 2 + gap; x + w / 2 <= plan.width; x += w + gap) {
|
||||
const candidate = placeInRoom(item, { x, y }, plan);
|
||||
if (!overlaps(candidate.x, candidate.y)) return candidate;
|
||||
}
|
||||
}
|
||||
return placeInRoom(item, { x: plan.width / 2, y: plan.depth / 2 }, plan);
|
||||
}
|
||||
|
|
@ -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<typeof roomSchema>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<RoomsListItemProps> = ({
|
||||
roomName,
|
||||
spotCount,
|
||||
onEdit,
|
||||
onDelete,
|
||||
showDeleteIcon,
|
||||
|
|
@ -21,6 +23,11 @@ const RoomsListItem: React.FC<RoomsListItemProps> = ({
|
|||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl text-gray-900">{roomName}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{spotCount === 0
|
||||
? "Ohne Grundriss"
|
||||
: `${spotCount} ${spotCount === 1 ? "Platz" : "Plätze"} im Grundriss`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{showDeleteIcon && (
|
||||
|
|
|
|||
496
src/modules/rooms/ui/components/floor-plan/FloorPlanEditor.tsx
Normal file
496
src/modules/rooms/ui/components/floor-plan/FloorPlanEditor.tsx
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState, type KeyboardEvent } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useDraggable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core";
|
||||
import { CSS, getEventCoordinates } from "@dnd-kit/utilities";
|
||||
import { Copy, RotateCw, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { cn } from "@/src/lib/utils";
|
||||
import {
|
||||
EQUIPMENT,
|
||||
EQUIPMENT_TYPES,
|
||||
ROOM_SIZE_MAX,
|
||||
ROOM_SIZE_MIN,
|
||||
SNAP_STEP,
|
||||
findFreePosition,
|
||||
isBookable,
|
||||
itemFootprint,
|
||||
nextLabel,
|
||||
placeInRoom,
|
||||
type EquipmentType,
|
||||
type FloorPlan,
|
||||
type FloorPlanItem,
|
||||
type Rotation,
|
||||
} from "../../../lib/floor-plan";
|
||||
import { EquipmentGraphic, EquipmentShape, FloorPlanStage, itemBoxStyle } from "./FloorPlanStage";
|
||||
|
||||
type DragData = { kind: "palette"; type: EquipmentType } | { kind: "item" };
|
||||
|
||||
const BOOKABLE_TYPES = EQUIPMENT_TYPES.filter((t) => EQUIPMENT[t].bookable);
|
||||
const ROOM_TYPES = EQUIPMENT_TYPES.filter((t) => !EQUIPMENT[t].bookable);
|
||||
|
||||
/** Legt die Mitte des gezogenen Geräts unter den Mauszeiger (wie @dnd-kit/modifiers). */
|
||||
const snapCenterToCursor: Modifier = ({ activatorEvent, draggingNodeRect, transform }) => {
|
||||
if (!draggingNodeRect || !activatorEvent) return transform;
|
||||
const coords = getEventCoordinates(activatorEvent);
|
||||
if (!coords) return transform;
|
||||
return {
|
||||
...transform,
|
||||
x: transform.x + coords.x - draggingNodeRect.left - draggingNodeRect.width / 2,
|
||||
y: transform.y + coords.y - draggingNodeRect.top - draggingNodeRect.height / 2,
|
||||
};
|
||||
};
|
||||
|
||||
interface FloorPlanEditorProps {
|
||||
value: FloorPlan;
|
||||
onChange: (plan: FloorPlan) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grundriss-Editor: Geräte aus der Leiste in den Raum ziehen (oder antippen),
|
||||
* im Raum verschieben, anklicken zum Drehen/Nummerieren/Entfernen.
|
||||
* Tastatur: Pfeile verschieben (mit Shift in 50-cm-Schritten), R dreht,
|
||||
* Entf entfernt, Esc hebt die Auswahl auf.
|
||||
*/
|
||||
export default function FloorPlanEditor({ value: plan, onChange }: FloorPlanEditorProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [paletteDrag, setPaletteDrag] = useState<{
|
||||
type: EquipmentType;
|
||||
pxPerMeter: number;
|
||||
} | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
// Kleiner Schwellwert, damit ein Klick ein Klick bleibt (auswählen/hinzufügen).
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
|
||||
// Am Handy erst nach kurzem Halten ziehen - sonst ließe sich die
|
||||
// Geräteleiste nicht mehr seitlich wischen.
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 6 } })
|
||||
);
|
||||
|
||||
const selected = plan.items.find((i) => i.id === selectedId) ?? null;
|
||||
const spotCount = plan.items.filter(isBookable).length;
|
||||
|
||||
const setItems = (items: FloorPlanItem[]) => onChange({ ...plan, items });
|
||||
const updateItem = (id: string, patch: Partial<FloorPlanItem>) =>
|
||||
setItems(plan.items.map((i) => (i.id === id ? { ...i, ...patch } : i)));
|
||||
|
||||
const addItem = (type: EquipmentType, at?: { x: number; y: number }) => {
|
||||
const base = { type, rotation: 0 as Rotation };
|
||||
const pos = at ? placeInRoom(base, at, plan) : findFreePosition(plan, base);
|
||||
const item: FloorPlanItem = {
|
||||
id: crypto.randomUUID(),
|
||||
...base,
|
||||
...pos,
|
||||
label: nextLabel(plan.items, type),
|
||||
};
|
||||
setItems([...plan.items, item]);
|
||||
setSelectedId(item.id);
|
||||
};
|
||||
|
||||
const moveBy = (item: FloorPlanItem, dx: number, dy: number) =>
|
||||
updateItem(item.id, placeInRoom(item, { x: item.x + dx, y: item.y + dy }, plan));
|
||||
|
||||
const rotate = (item: FloorPlanItem) => {
|
||||
const rotation = ((item.rotation + 90) % 360) as Rotation;
|
||||
// Nach dem Drehen kann ein Gerät über die Wand ragen - wieder hineinschieben.
|
||||
updateItem(item.id, { rotation, ...placeInRoom({ ...item, rotation }, item, plan) });
|
||||
};
|
||||
|
||||
const remove = (item: FloorPlanItem) => {
|
||||
setItems(plan.items.filter((i) => i.id !== item.id));
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
// Ein Duplikat landet direkt rechts daneben - so entsteht eine Reihe Reformer
|
||||
// mit ein paar Klicks. Ist rechts kein Platz mehr, sucht es sich eine Lücke.
|
||||
const duplicate = (item: FloorPlanItem) => {
|
||||
const { w } = itemFootprint(item);
|
||||
const besideX = item.x + w + 0.3;
|
||||
const fits = besideX + w / 2 <= plan.width;
|
||||
const copy = { type: item.type, rotation: item.rotation };
|
||||
const pos = fits
|
||||
? placeInRoom(copy, { x: besideX, y: item.y }, plan)
|
||||
: findFreePosition(plan, copy);
|
||||
const newItem: FloorPlanItem = {
|
||||
id: crypto.randomUUID(),
|
||||
...copy,
|
||||
...pos,
|
||||
label: nextLabel(plan.items, item.type),
|
||||
};
|
||||
setItems([...plan.items, newItem]);
|
||||
setSelectedId(newItem.id);
|
||||
};
|
||||
|
||||
const setRoomSize = (patch: { width?: number; depth?: number }) => {
|
||||
const next = { ...plan, ...patch };
|
||||
onChange({
|
||||
...next,
|
||||
items: next.items.map((i) => ({ ...i, ...placeInRoom(i, i, next) })),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragStart = ({ active }: DragStartEvent) => {
|
||||
const data = active.data.current as DragData | undefined;
|
||||
if (data?.kind === "palette") {
|
||||
const width = stageRef.current?.getBoundingClientRect().width ?? 0;
|
||||
setPaletteDrag({ type: data.type, pxPerMeter: width / plan.width });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = ({ active, delta, activatorEvent }: DragEndEvent) => {
|
||||
setPaletteDrag(null);
|
||||
const data = active.data.current as DragData | undefined;
|
||||
const rect = stageRef.current?.getBoundingClientRect();
|
||||
if (!data || !rect) return;
|
||||
const pxPerMeter = rect.width / plan.width;
|
||||
|
||||
if (data.kind === "item") {
|
||||
const item = plan.items.find((i) => i.id === active.id);
|
||||
if (item) moveBy(item, delta.x / pxPerMeter, delta.y / pxPerMeter);
|
||||
setSelectedId(String(active.id));
|
||||
return;
|
||||
}
|
||||
|
||||
// Aus der Leiste: abgelegt wird, wo der Zeiger losgelassen wurde.
|
||||
const start = getEventCoordinates(activatorEvent);
|
||||
if (!start) return;
|
||||
const px = start.x + delta.x;
|
||||
const py = start.y + delta.y;
|
||||
if (px < rect.left || px > rect.right || py < rect.top || py > rect.bottom) return;
|
||||
addItem(data.type, { x: (px - rect.left) / pxPerMeter, y: (py - rect.top) / pxPerMeter });
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!selected) return;
|
||||
const step = e.shiftKey ? 0.5 : SNAP_STEP;
|
||||
const actions: Record<string, () => void> = {
|
||||
ArrowLeft: () => moveBy(selected, -step, 0),
|
||||
ArrowRight: () => moveBy(selected, step, 0),
|
||||
ArrowUp: () => moveBy(selected, 0, -step),
|
||||
ArrowDown: () => moveBy(selected, 0, step),
|
||||
r: () => rotate(selected),
|
||||
R: () => rotate(selected),
|
||||
Delete: () => remove(selected),
|
||||
Backspace: () => remove(selected),
|
||||
Escape: () => setSelectedId(null),
|
||||
};
|
||||
const action = actions[e.key];
|
||||
if (action) {
|
||||
e.preventDefault();
|
||||
action();
|
||||
}
|
||||
};
|
||||
|
||||
const labelTaken =
|
||||
selected !== null &&
|
||||
isBookable(selected) &&
|
||||
plan.items.some(
|
||||
(i) =>
|
||||
i.id !== selected.id &&
|
||||
i.type === selected.type &&
|
||||
i.label.toLowerCase() === selected.label.trim().toLowerCase()
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Raumgröße + Zähler */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<RoomSizeInput
|
||||
label="Breite"
|
||||
value={plan.width}
|
||||
onChange={(width) => setRoomSize({ width })}
|
||||
/>
|
||||
<span className="pb-2.5 text-gray-400">×</span>
|
||||
<RoomSizeInput
|
||||
label="Tiefe"
|
||||
value={plan.depth}
|
||||
onChange={(depth) => setRoomSize({ depth })}
|
||||
/>
|
||||
<span className="ml-auto pb-2 text-sm text-gray-600">
|
||||
<span className="font-semibold text-gray-900">{spotCount}</span>{" "}
|
||||
{spotCount === 1 ? "buchbarer Platz" : "buchbare Plätze"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
{/* Geräteleiste: am Handy eine wischbare Reihe, ab md eine Spalte */}
|
||||
<div className="flex shrink-0 gap-2 overflow-x-auto pb-1 md:w-40 md:flex-col md:overflow-visible md:pb-0">
|
||||
<PaletteHeading>Buchbar</PaletteHeading>
|
||||
{BOOKABLE_TYPES.map((type) => (
|
||||
<PaletteItem key={type} type={type} onAdd={() => addItem(type)} />
|
||||
))}
|
||||
<PaletteHeading className="md:mt-2">Raum</PaletteHeading>
|
||||
{ROOM_TYPES.map((type) => (
|
||||
<PaletteItem key={type} type={type} onAdd={() => addItem(type)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
{/* Auswahl-Leiste */}
|
||||
<div className="flex min-h-11 flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white/70 px-3 py-1.5">
|
||||
{selected ? (
|
||||
<>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{EQUIPMENT[selected.type].label}
|
||||
</span>
|
||||
{isBookable(selected) && (
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600">
|
||||
Nr.
|
||||
<input
|
||||
value={selected.label}
|
||||
maxLength={12}
|
||||
onChange={(e) => updateItem(selected.id, { label: e.target.value })}
|
||||
aria-invalid={labelTaken || !selected.label.trim()}
|
||||
className={cn(
|
||||
"h-8 w-16 rounded-md border px-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-[#c9a84c]/40",
|
||||
labelTaken || !selected.label.trim()
|
||||
? "border-red-400"
|
||||
: "border-gray-300"
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{labelTaken && <span className="text-xs text-red-600">Nummer gibt es schon</span>}
|
||||
<div className="ml-auto flex gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={() => rotate(selected)}>
|
||||
<RotateCw className="size-4" /> Drehen
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => duplicate(selected)}>
|
||||
<Copy className="size-4" /> Duplizieren
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => remove(selected)}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
aria-label="Gerät entfernen"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">
|
||||
Gerät anklicken, um es zu drehen, zu nummerieren oder zu entfernen.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Raum */}
|
||||
<div onKeyDown={handleKeyDown}>
|
||||
<FloorPlanStage
|
||||
plan={plan}
|
||||
stageRef={stageRef}
|
||||
maxHeight="65vh"
|
||||
onClick={() => setSelectedId(null)}
|
||||
>
|
||||
{plan.items.length === 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center p-4 text-center text-sm text-gray-400">
|
||||
Geräte hierher ziehen oder in der Leiste antippen
|
||||
</div>
|
||||
)}
|
||||
{plan.items.map((item) => (
|
||||
<PlacedItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
plan={plan}
|
||||
selected={item.id === selectedId}
|
||||
onSelect={() => setSelectedId(item.id)}
|
||||
/>
|
||||
))}
|
||||
</FloorPlanStage>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay modifiers={[snapCenterToCursor]} dropAnimation={null}>
|
||||
{paletteDrag && (
|
||||
<div
|
||||
className="relative opacity-80"
|
||||
style={{
|
||||
width: EQUIPMENT[paletteDrag.type].w * paletteDrag.pxPerMeter,
|
||||
height: EQUIPMENT[paletteDrag.type].h * paletteDrag.pxPerMeter,
|
||||
}}
|
||||
>
|
||||
<EquipmentGraphic
|
||||
item={{
|
||||
type: paletteDrag.type,
|
||||
rotation: 0,
|
||||
label: nextLabel(plan.items, paletteDrag.type),
|
||||
}}
|
||||
tone="selected"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteHeading({ children, className }: { children: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"hidden text-xs font-semibold uppercase tracking-wide text-gray-400 md:block",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteItem({ type, onAdd }: { type: EquipmentType; onAdd: () => void }) {
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||
id: `palette:${type}`,
|
||||
data: { kind: "palette", type } satisfies DragData,
|
||||
});
|
||||
const { w, h, label } = EQUIPMENT[type];
|
||||
// Vorschau in einem 28-px-Kasten, maßstabsgetreu zueinander.
|
||||
const scale = 28 / Math.max(w, h);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={onAdd}
|
||||
aria-label={`${label} hinzufügen`}
|
||||
className="flex shrink-0 cursor-grab touch-manipulation items-center gap-2.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-sm text-gray-700 transition-colors hover:border-[#c9a84c]/60 hover:bg-[#c9a84c]/5 active:cursor-grabbing"
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center">
|
||||
<span
|
||||
className="relative"
|
||||
style={
|
||||
{
|
||||
width: w * scale,
|
||||
height: h * scale,
|
||||
"--fp-fill": "#ffffff",
|
||||
"--fp-stroke": "#64748b",
|
||||
"--fp-detail": "#cbd5e1",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<EquipmentShape type={type} rotation={0} />
|
||||
</span>
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PlacedItem({
|
||||
item,
|
||||
plan,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
item: FloorPlanItem;
|
||||
plan: FloorPlan;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: item.id,
|
||||
data: { kind: "item" } satisfies DragData,
|
||||
});
|
||||
const name = item.label
|
||||
? `${EQUIPMENT[item.type].label} ${item.label}`
|
||||
: EQUIPMENT[item.type].label;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
onFocus={onSelect}
|
||||
aria-label={name}
|
||||
title={name}
|
||||
className={cn(
|
||||
"cursor-grab touch-none select-none rounded-sm outline-none active:cursor-grabbing",
|
||||
"focus-visible:ring-2 focus-visible:ring-[#c9a84c] focus-visible:ring-offset-1"
|
||||
)}
|
||||
style={{
|
||||
...itemBoxStyle(item, plan),
|
||||
transform: CSS.Translate.toString(transform),
|
||||
zIndex: isDragging ? 30 : selected ? 20 : undefined,
|
||||
}}
|
||||
>
|
||||
<EquipmentGraphic item={item} tone={selected ? "selected" : "default"} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomSizeInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
// Eigener Textzustand: beim Tippen von "12" ist "1" kurz ungültig und darf
|
||||
// die Geräte nicht schon an die Wand schieben.
|
||||
const [text, setText] = useState(String(value).replace(".", ","));
|
||||
const [lastValue, setLastValue] = useState(value);
|
||||
if (value !== lastValue) {
|
||||
setLastValue(value);
|
||||
setText(String(value).replace(".", ","));
|
||||
}
|
||||
|
||||
const parse = (raw: string) => {
|
||||
const n = Number(raw.replace(",", "."));
|
||||
return Number.isFinite(n) && n >= ROOM_SIZE_MIN && n <= ROOM_SIZE_MAX
|
||||
? Math.round(n * 10) / 10
|
||||
: null;
|
||||
};
|
||||
const invalid = parse(text) === null;
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-1 text-sm text-gray-600">
|
||||
{label}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
const n = parse(e.target.value);
|
||||
if (n !== null) {
|
||||
setLastValue(n);
|
||||
onChange(n);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setText(String(value).replace(".", ","))}
|
||||
aria-invalid={invalid}
|
||||
className={cn(
|
||||
"h-10 w-20 rounded-lg border px-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-[#c9a84c]/40",
|
||||
invalid ? "border-red-400" : "border-gray-300"
|
||||
)}
|
||||
/>
|
||||
<span className="text-gray-500">m</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
267
src/modules/rooms/ui/components/floor-plan/FloorPlanStage.tsx
Normal file
267
src/modules/rooms/ui/components/floor-plan/FloorPlanStage.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import type { CSSProperties, ReactNode, Ref } from "react";
|
||||
import { cn } from "@/src/lib/utils";
|
||||
import {
|
||||
EQUIPMENT,
|
||||
isBookable,
|
||||
itemFootprint,
|
||||
type EquipmentType,
|
||||
type FloorPlan,
|
||||
type FloorPlanItem,
|
||||
type Rotation,
|
||||
} from "../../../lib/floor-plan";
|
||||
|
||||
/**
|
||||
* Gemeinsame Darstellung des Grundrisses für Editor (Raum bearbeiten) und
|
||||
* Platzwahl (Buchungsdialog). Alles wird in Prozent des Raums positioniert -
|
||||
* die Bühne hat das Seitenverhältnis des Raums, damit skaliert der Plan mit
|
||||
* jeder Bildschirmbreite, ohne dass jemand Pixel umrechnen muss.
|
||||
*/
|
||||
|
||||
export type EquipmentTone = "default" | "selected" | "free" | "taken" | "picked" | "decor";
|
||||
|
||||
type ToneColors = { fill: string; stroke: string; detail: string; text: string };
|
||||
|
||||
const TONES: Record<Exclude<EquipmentTone, "picked">, ToneColors> = {
|
||||
default: { fill: "#ffffff", stroke: "#64748b", detail: "#cbd5e1", text: "#334155" },
|
||||
selected: { fill: "#fdf8ec", stroke: "#c9a84c", detail: "#e7d6a3", text: "#7a5f17" },
|
||||
free: { fill: "#ffffff", stroke: "#475569", detail: "#cbd5e1", text: "#1e293b" },
|
||||
taken: { fill: "#e2e8f0", stroke: "#cbd5e1", detail: "#cbd5e1", text: "#94a3b8" },
|
||||
decor: { fill: "#f8fafc", stroke: "#cbd5e1", detail: "#e2e8f0", text: "#94a3b8" },
|
||||
};
|
||||
|
||||
function toneColors(tone: EquipmentTone, accent?: { color: string; text: string }): ToneColors {
|
||||
if (tone === "picked") {
|
||||
const color = accent?.color ?? "#c9a84c";
|
||||
return {
|
||||
fill: color,
|
||||
stroke: color,
|
||||
detail: "rgba(255,255,255,0.45)",
|
||||
text: accent?.text ?? "#fff",
|
||||
};
|
||||
}
|
||||
return TONES[tone];
|
||||
}
|
||||
|
||||
/** Position und Größe eines Geräts in Prozent der Bühne. */
|
||||
export function itemBoxStyle(
|
||||
item: Pick<FloorPlanItem, "type" | "rotation" | "x" | "y">,
|
||||
plan: Pick<FloorPlan, "width" | "depth">
|
||||
): CSSProperties {
|
||||
const { w, h } = itemFootprint(item);
|
||||
return {
|
||||
position: "absolute",
|
||||
left: `${((item.x - w / 2) / plan.width) * 100}%`,
|
||||
top: `${((item.y - h / 2) / plan.depth) * 100}%`,
|
||||
width: `${(w / plan.width) * 100}%`,
|
||||
height: `${(h / plan.depth) * 100}%`,
|
||||
};
|
||||
}
|
||||
|
||||
interface FloorPlanStageProps {
|
||||
plan: Pick<FloorPlan, "width" | "depth">;
|
||||
children?: ReactNode;
|
||||
/** Begrenzt die Höhe - ein tiefer, schmaler Raum soll nicht den ganzen Bildschirm füllen. */
|
||||
maxHeight?: string;
|
||||
className?: string;
|
||||
stageRef?: Ref<HTMLDivElement>;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function FloorPlanStage({
|
||||
plan,
|
||||
children,
|
||||
maxHeight = "60vh",
|
||||
className,
|
||||
stageRef,
|
||||
onClick,
|
||||
}: FloorPlanStageProps) {
|
||||
const ratio = plan.width / plan.depth;
|
||||
return (
|
||||
<div
|
||||
className="w-full"
|
||||
style={{ maxWidth: `calc(${maxHeight} * ${ratio})`, marginInline: "auto" }}
|
||||
>
|
||||
<div
|
||||
ref={stageRef}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border-2 border-slate-300 bg-white [container-type:inline-size]",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
aspectRatio: `${plan.width} / ${plan.depth}`,
|
||||
// Ein Rasterkästchen = 1 Meter
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgb(148 163 184 / 0.18) 1px, transparent 1px), linear-gradient(to bottom, rgb(148 163 184 / 0.18) 1px, transparent 1px)",
|
||||
backgroundSize: `${100 / plan.width}% ${100 / plan.depth}%`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Gerät samt Platznummer, füllt seinen (per itemBoxStyle positionierten) Container. */
|
||||
export function EquipmentGraphic({
|
||||
item,
|
||||
tone,
|
||||
accent,
|
||||
}: {
|
||||
item: Pick<FloorPlanItem, "type" | "rotation" | "label">;
|
||||
tone: EquipmentTone;
|
||||
accent?: { color: string; text: string };
|
||||
}) {
|
||||
const colors = toneColors(tone, accent);
|
||||
return (
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={
|
||||
{
|
||||
"--fp-fill": colors.fill,
|
||||
"--fp-stroke": colors.stroke,
|
||||
"--fp-detail": colors.detail,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<EquipmentShape type={item.type} rotation={item.rotation} />
|
||||
{isBookable(item) && item.label && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<span
|
||||
className="rounded-full px-[0.35em] font-semibold leading-tight tabular-nums"
|
||||
style={{
|
||||
fontSize: "clamp(9px, 1.7cqw, 14px)",
|
||||
color: colors.text,
|
||||
backgroundColor: colors.fill,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draufsicht eines Geräts. Gezeichnet in Zentimetern, ungedreht (w entlang x,
|
||||
* h entlang y), dann um den Mittelpunkt gedreht. Die Linienstärke bleibt beim
|
||||
* Skalieren gleich (non-scaling-stroke), damit Geräte auch klein lesbar sind.
|
||||
*/
|
||||
export function EquipmentShape({ type, rotation }: { type: EquipmentType; rotation: Rotation }) {
|
||||
const W = EQUIPMENT[type].w * 100;
|
||||
const H = EQUIPMENT[type].h * 100;
|
||||
const turned = rotation % 180 !== 0;
|
||||
const fw = turned ? H : W;
|
||||
const fh = turned ? W : H;
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${fw} ${fh}`}
|
||||
preserveAspectRatio="none"
|
||||
className="absolute inset-0 size-full overflow-visible"
|
||||
aria-hidden
|
||||
>
|
||||
<g
|
||||
transform={`translate(${fw / 2} ${fh / 2}) rotate(${rotation}) translate(${-W / 2} ${-H / 2})`}
|
||||
fill="var(--fp-fill)"
|
||||
stroke="var(--fp-stroke)"
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
>
|
||||
{DRAWINGS[type](W, H)}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const line = { vectorEffect: "non-scaling-stroke" as const };
|
||||
const detail = { ...line, stroke: "var(--fp-detail)", fill: "var(--fp-detail)" };
|
||||
const detailLine = { ...line, stroke: "var(--fp-detail)", fill: "none" };
|
||||
|
||||
function reformer(W: number, H: number) {
|
||||
return (
|
||||
<>
|
||||
<rect x={0} y={0} width={W} height={H} rx={8} {...line} />
|
||||
{/* Fußstange */}
|
||||
<line x1={8} y1={22} x2={W - 8} y2={22} strokeWidth={2} {...detailLine} />
|
||||
{/* Schlitten */}
|
||||
<rect x={10} y={H * 0.4} width={W - 20} height={H * 0.32} rx={6} {...detail} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DRAWINGS: Record<EquipmentType, (W: number, H: number) => React.ReactNode> = {
|
||||
reformer,
|
||||
tower: (W, H) => (
|
||||
<>
|
||||
{reformer(W, H)}
|
||||
{/* Turm am Kopfende */}
|
||||
<rect x={-4} y={H - 18} width={W + 8} height={18} rx={3} {...line} />
|
||||
</>
|
||||
),
|
||||
cadillac: (W, H) => (
|
||||
<>
|
||||
<rect x={0} y={0} width={W} height={H} rx={4} {...line} />
|
||||
<rect x={9} y={9} width={W - 18} height={H - 18} rx={4} {...detail} />
|
||||
{[
|
||||
[5, 5],
|
||||
[W - 5, 5],
|
||||
[5, H - 5],
|
||||
[W - 5, H - 5],
|
||||
].map(([cx, cy]) => (
|
||||
<circle key={`${cx}-${cy}`} cx={cx} cy={cy} r={6} {...line} />
|
||||
))}
|
||||
</>
|
||||
),
|
||||
chair: (W, H) => (
|
||||
<>
|
||||
<rect x={0} y={0} width={W} height={H} rx={6} {...line} />
|
||||
<rect x={8} y={8} width={W - 16} height={H * 0.5} rx={4} {...detail} />
|
||||
{/* Pedal */}
|
||||
<line x1={10} y1={H - 14} x2={W - 10} y2={H - 14} strokeWidth={2} {...detailLine} />
|
||||
</>
|
||||
),
|
||||
barrel: (W, H) => (
|
||||
<>
|
||||
<rect x={0} y={0} width={W} height={H} rx={6} {...line} />
|
||||
{/* Sprossen */}
|
||||
{[0.12, 0.24, 0.36].map((f) => (
|
||||
<line key={f} x1={8} y1={H * f} x2={W - 8} y2={H * f} {...detailLine} />
|
||||
))}
|
||||
{/* Fass */}
|
||||
<rect x={6} y={H * 0.5} width={W - 12} height={H * 0.44} rx={(W - 12) / 2} {...detail} />
|
||||
</>
|
||||
),
|
||||
mat: (W, H) => (
|
||||
<>
|
||||
<rect x={0} y={0} width={W} height={H} rx={10} {...line} />
|
||||
{[0.25, 0.5, 0.75].map((f) => (
|
||||
<line key={f} x1={8} y1={H * f} x2={W - 8} y2={H * f} {...detailLine} />
|
||||
))}
|
||||
</>
|
||||
),
|
||||
trainer: (W, H) => (
|
||||
<>
|
||||
<circle cx={W / 2} cy={H / 2} r={W / 2 - 2} strokeDasharray="6 5" {...line} />
|
||||
<circle cx={W / 2} cy={H / 2 - 10} r={10} {...detail} />
|
||||
<path
|
||||
d={`M ${W / 2 - 20} ${H / 2 + 24} Q ${W / 2} ${H / 2 - 2} ${W / 2 + 20} ${H / 2 + 24} Z`}
|
||||
{...detail}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
door: (W, H) => (
|
||||
<>
|
||||
<rect x={0} y={0} width={W} height={H} {...line} />
|
||||
{/* Türblatt und Schwenkbereich - ragen bewusst in den Raum hinein */}
|
||||
<path
|
||||
d={`M 0 ${H} L 0 ${H + W}`}
|
||||
strokeWidth={1.5}
|
||||
{...detailLine}
|
||||
stroke="var(--fp-stroke)"
|
||||
/>
|
||||
<path d={`M ${W} ${H} A ${W} ${W} 0 0 1 0 ${H + W}`} strokeDasharray="5 5" {...detailLine} />
|
||||
</>
|
||||
),
|
||||
mirror: (W, H) => <rect x={0} y={0} width={W} height={H} fill="#e0f2fe" {...line} />,
|
||||
};
|
||||
117
src/modules/rooms/ui/components/floor-plan/SpotPicker.tsx
Normal file
117
src/modules/rooms/ui/components/floor-plan/SpotPicker.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/src/lib/utils";
|
||||
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||
import { isBookable, spotDisplayName, type FloorPlan } from "../../../lib/floor-plan";
|
||||
import {
|
||||
EquipmentGraphic,
|
||||
FloorPlanStage,
|
||||
itemBoxStyle,
|
||||
type EquipmentTone,
|
||||
} from "./FloorPlanStage";
|
||||
|
||||
const DEFAULT_ACCENT = "#c9a84c";
|
||||
|
||||
interface SpotPickerProps {
|
||||
floorPlan: FloorPlan;
|
||||
/** Bezahlt gebucht oder gerade von jemand anderem im Checkout. */
|
||||
takenSpotIds: string[];
|
||||
selectedSpotId: string | null;
|
||||
/** Platz der eigenen Buchung - wird hervorgehoben, ist aber nicht wählbar. */
|
||||
mySpotId?: string | null;
|
||||
/** Fehlt = nur ansehen. */
|
||||
onSelect?: (spotId: string) => void;
|
||||
/** Akzentfarbe des Studios für den gewählten Platz. */
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grundriss im Buchungsdialog: freie Geräte antippen, belegte sind grau.
|
||||
* Trainer, Tür und Spiegel helfen nur bei der Orientierung und sind nicht wählbar.
|
||||
*/
|
||||
export function SpotPicker({
|
||||
floorPlan,
|
||||
takenSpotIds,
|
||||
selectedSpotId,
|
||||
mySpotId = null,
|
||||
onSelect,
|
||||
accentColor = DEFAULT_ACCENT,
|
||||
}: SpotPickerProps) {
|
||||
const taken = new Set(takenSpotIds);
|
||||
const accent = { color: accentColor, text: getContrastTextColor(accentColor) };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<FloorPlanStage plan={floorPlan} maxHeight="45vh">
|
||||
{floorPlan.items.map((item) => {
|
||||
if (!isBookable(item)) {
|
||||
return (
|
||||
<div key={item.id} style={itemBoxStyle(item, floorPlan)} aria-hidden>
|
||||
<EquipmentGraphic item={item} tone="decor" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isMine = item.id === mySpotId;
|
||||
const isTaken = !isMine && taken.has(item.id);
|
||||
const isSelected = item.id === selectedSpotId;
|
||||
const tone: EquipmentTone = isMine || isSelected ? "picked" : isTaken ? "taken" : "free";
|
||||
const name = spotDisplayName(item);
|
||||
const state = isMine
|
||||
? "Ihr Platz"
|
||||
: isTaken
|
||||
? "belegt"
|
||||
: isSelected
|
||||
? "ausgewählt"
|
||||
: "frei";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={!onSelect || isTaken || isMine}
|
||||
onClick={() => onSelect?.(item.id)}
|
||||
aria-pressed={onSelect ? isSelected : undefined}
|
||||
aria-label={`${name}, ${state}`}
|
||||
title={`${name} – ${state}`}
|
||||
className={cn(
|
||||
"rounded-sm outline-none transition-transform",
|
||||
"focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-1",
|
||||
"enabled:cursor-pointer enabled:hover:scale-[1.04] disabled:cursor-default",
|
||||
isSelected && "z-10"
|
||||
)}
|
||||
style={itemBoxStyle(item, floorPlan)}
|
||||
>
|
||||
<EquipmentGraphic item={item} tone={tone} accent={accent} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</FloorPlanStage>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500">
|
||||
<LegendSwatch className="border-slate-500 bg-white">Frei</LegendSwatch>
|
||||
<LegendSwatch className="border-slate-300 bg-slate-200">Belegt</LegendSwatch>
|
||||
<LegendSwatch style={{ backgroundColor: accent.color, borderColor: accent.color }}>
|
||||
{mySpotId ? "Ihr Platz" : "Ihre Wahl"}
|
||||
</LegendSwatch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendSwatch({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn("size-3 rounded-sm border", className)} style={style} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<FloorPlan>(
|
||||
() => parseFloorPlan(initialData?.floorPlan) ?? { ...DEFAULT_ROOM_SIZE, items: [] }
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
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
|
|||
</h1>
|
||||
<p className="text-xl mt-2">
|
||||
{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"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -105,8 +122,8 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col gap-4 max-w-xl">
|
||||
<div>
|
||||
<div className="mt-6 flex flex-col gap-5 max-w-5xl">
|
||||
<div className="max-w-xl">
|
||||
<InputComponent
|
||||
isLabel
|
||||
label="Name"
|
||||
|
|
@ -117,7 +134,25 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
|
|||
/>
|
||||
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 sm:p-6">
|
||||
<div className="flex items-start gap-2 mb-5">
|
||||
<div className="flex items-center justify-center size-8 shrink-0 rounded-lg bg-[#c9a84c]/10 text-[#c9a84c]">
|
||||
<LayoutGrid className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900">Grundriss (optional)</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Stellen Sie Reformer, Matten und andere Geräte so auf, wie sie im Raum stehen. Bei
|
||||
Kursen mit Platzwahl suchen sich Kunden ihr Gerät dann selbst aus.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<FloorPlanEditor value={floorPlan} onChange={setFloorPlan} />
|
||||
{errors.floorPlan && <p className="text-red-500 text-sm mt-2">{errors.floorPlan}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pb-4">
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? "Wird gespeichert..."
|
||||
|
|
|
|||
|
|
@ -4,14 +4,8 @@ import { useRouter } from "next/navigation";
|
|||
import RoomsListItem from "../components/RoomsListItem";
|
||||
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
||||
import { useDeleteRoom } from "../../hooks/useDeleteRoom";
|
||||
|
||||
type Room = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
import { bookableSpots, parseFloorPlan } from "../../lib/floor-plan";
|
||||
import type { Room } from "../../types/room.types";
|
||||
|
||||
interface RoomsListViewProps {
|
||||
rooms: Room[];
|
||||
|
|
@ -51,6 +45,7 @@ const RoomsListView = ({ rooms, deleteMode, canEdit, canDelete }: RoomsListViewP
|
|||
<RoomsListItem
|
||||
key={room.id}
|
||||
roomName={room.name}
|
||||
spotCount={bookableSpots(parseFloorPlan(room.floorPlan)).length}
|
||||
onEdit={canEdit ? () => handleEditClick(room.id) : undefined}
|
||||
onDelete={canDelete ? () => handleDeleteClick(room.id, room.name) : undefined}
|
||||
showDeleteIcon={deleteMode && canDelete}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,7 @@ import { useState } from "react";
|
|||
import { Button } from "@/src/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import RoomsListView from "./rooms-list-view";
|
||||
|
||||
type Room = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
import type { Room } from "../../types/room.types";
|
||||
|
||||
interface RoomsViewProps {
|
||||
rooms: Room[];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { POST } from "@/src/app/api/stripe/checkout/course/route";
|
||||
|
||||
vi.mock("@/src/lib/auth/getUser", () => ({
|
||||
|
|
@ -18,15 +18,46 @@ vi.mock("@/src/lib/prisma", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/src/lib/stripe", () => ({
|
||||
stripe: { checkout: { sessions: { create: vi.fn() } } },
|
||||
stripe: { checkout: { sessions: { create: vi.fn(), retrieve: vi.fn(), expire: vi.fn() } } },
|
||||
getPlatformFeeCents: vi.fn().mockReturnValue(50),
|
||||
eurosToCents: vi.fn().mockReturnValue(2900),
|
||||
}));
|
||||
|
||||
// Rabattcodes nur für den Test, dass ein Fehler bei der Platzwahl die
|
||||
// Reservierung wieder freigibt.
|
||||
vi.mock("@/src/modules/settings/lib/discount-validation", () => ({
|
||||
reserveDiscountCode: vi.fn(),
|
||||
releaseDiscountReservation: vi.fn().mockResolvedValue(undefined),
|
||||
calculateDiscountedPriceCents: () => ({ discountedPriceCents: 2000, discountAmountCents: 900 }),
|
||||
}));
|
||||
|
||||
// Kurse ohne Platzwahl liefern null - so bleiben alle übrigen Tests unberührt.
|
||||
vi.mock("@/src/modules/courses/lib/course-spots", () => ({
|
||||
getCourseSpots: vi.fn().mockResolvedValue(null),
|
||||
getOpenCheckoutHolds: vi.fn().mockResolvedValue([]),
|
||||
attachSessionToHold: vi.fn().mockResolvedValue(undefined),
|
||||
holdSpot: vi.fn(),
|
||||
releaseSpotHold: vi.fn().mockResolvedValue(undefined),
|
||||
spotHolderKey: (p: { userId?: string | null; guestEmail?: string | null }) =>
|
||||
p.userId ? `user:${p.userId}` : `guest:${p.guestEmail}`,
|
||||
SPOT_HOLD_MINUTES: 30,
|
||||
}));
|
||||
|
||||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { stripe, getPlatformFeeCents } from "@/src/lib/stripe";
|
||||
import {
|
||||
attachSessionToHold,
|
||||
getCourseSpots,
|
||||
getOpenCheckoutHolds,
|
||||
holdSpot,
|
||||
releaseSpotHold,
|
||||
} from "@/src/modules/courses/lib/course-spots";
|
||||
import {
|
||||
reserveDiscountCode,
|
||||
releaseDiscountReservation,
|
||||
} from "@/src/modules/settings/lib/discount-validation";
|
||||
|
||||
let ipCounter = 0;
|
||||
|
||||
|
|
@ -230,3 +261,187 @@ describe("POST /api/stripe/checkout/course – Checkout-Seite", () => {
|
|||
expect(retry.billing_address_collection).toBe("required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/stripe/checkout/course – Platzwahl", () => {
|
||||
const reformer = (id: string, label: string) => ({
|
||||
id,
|
||||
type: "reformer" as const,
|
||||
x: 1,
|
||||
y: 1.5,
|
||||
rotation: 0 as const,
|
||||
label,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
|
||||
vi.mocked(isUser).mockReturnValue(true);
|
||||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(getCourseSpots).mockResolvedValue([reformer("spot-1", "1"), reformer("spot-2", "2")]);
|
||||
vi.mocked(holdSpot).mockReset();
|
||||
vi.mocked(releaseSpotHold).mockClear();
|
||||
vi.mocked(attachSessionToHold).mockClear();
|
||||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
|
||||
vi.mocked(stripe.checkout.sessions.retrieve).mockReset();
|
||||
vi.mocked(stripe.checkout.sessions.expire).mockReset();
|
||||
vi.mocked(releaseDiscountReservation).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(getCourseSpots).mockResolvedValue(null);
|
||||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("verlangt einen Platz, bevor es zu Stripe geht", async () => {
|
||||
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).spotUnavailable).toBe(true);
|
||||
expect(holdSpot).not.toHaveBeenCalled();
|
||||
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lehnt einen Platz ab, den gerade jemand anderes bucht", async () => {
|
||||
vi.mocked(holdSpot).mockResolvedValue({ held: false, reason: "Platz gerade vergeben" });
|
||||
|
||||
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: "Platz gerade vergeben",
|
||||
spotUnavailable: true,
|
||||
});
|
||||
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hält den Platz und lässt die Session mit dem Hold verfallen", async () => {
|
||||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||
|
||||
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(vi.mocked(holdSpot).mock.calls[0][1]).toMatchObject({
|
||||
courseId: "course-1",
|
||||
spotId: "spot-2",
|
||||
holderKey: "user:user-1",
|
||||
});
|
||||
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
|
||||
expires_at?: number;
|
||||
metadata: Record<string, string>;
|
||||
line_items: { price_data: { product_data: { description: string } } }[];
|
||||
};
|
||||
expect(args.metadata).toMatchObject({ spotId: "spot-2", spotHoldId: "hold-1" });
|
||||
expect(args.line_items[0].price_data.product_data.description).toContain("Reformer 2");
|
||||
const inMinutes = (args.expires_at! * 1000 - Date.now()) / 60_000;
|
||||
expect(inMinutes).toBeGreaterThan(29);
|
||||
expect(inMinutes).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it("gibt den Platz frei, wenn Stripe die Session nicht anlegt", async () => {
|
||||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||
vi.mocked(stripe.checkout.sessions.create).mockRejectedValueOnce(new Error("Stripe down"));
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(releaseSpotHold).toHaveBeenCalledWith(prisma, "hold-1");
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("setzt ohne Platzwahl kein Ablaufdatum - Stripes Standard bleibt", async () => {
|
||||
vi.mocked(getCourseSpots).mockResolvedValue(null);
|
||||
|
||||
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
|
||||
expires_at?: number;
|
||||
metadata: Record<string, string>;
|
||||
};
|
||||
expect(args.expires_at).toBeUndefined();
|
||||
expect(args.metadata.spotId).toBe("");
|
||||
expect(holdSpot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gibt den Rabattcode frei, wenn die Platzwahl mit einem Fehler abbricht", async () => {
|
||||
vi.mocked(reserveDiscountCode).mockResolvedValue({
|
||||
reserved: true,
|
||||
reservationToken: "tok-1",
|
||||
discountCode: { id: "code-1", type: "percent", value: 30 },
|
||||
} as never);
|
||||
vi.mocked(holdSpot).mockRejectedValue(new Error("DB weg"));
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ courseId: "course-1", spotId: "spot-1", discountCode: "PILATES30" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(releaseDiscountReservation).toHaveBeenCalledWith(prisma, "tok-1");
|
||||
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("hängt die neue Session an den Hold", async () => {
|
||||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||
vi.mocked(stripe.checkout.sessions.create).mockResolvedValueOnce({
|
||||
id: "cs_neu",
|
||||
url: "https://checkout.stripe.com/neu",
|
||||
} as never);
|
||||
|
||||
await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
|
||||
|
||||
expect(attachSessionToHold).toHaveBeenCalledWith(prisma, "hold-1", "cs_neu");
|
||||
});
|
||||
|
||||
it("beendet einen alten, noch offenen Checkout derselben Person", async () => {
|
||||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||||
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||||
]);
|
||||
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
|
||||
id: "cs_alt",
|
||||
status: "open",
|
||||
} as never);
|
||||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||
|
||||
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||
|
||||
expect(stripe.checkout.sessions.expire).toHaveBeenCalledWith(
|
||||
"cs_alt",
|
||||
{},
|
||||
{ stripeAccount: "acct_123" }
|
||||
);
|
||||
expect(releaseSpotHold).toHaveBeenCalledWith(prisma, "hold-alt");
|
||||
});
|
||||
|
||||
it("lässt den Platz eines schon bezahlten Checkouts stehen, bis der Webhook kommt", async () => {
|
||||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||||
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||||
]);
|
||||
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
|
||||
id: "cs_alt",
|
||||
status: "complete",
|
||||
} as never);
|
||||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||
|
||||
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||
|
||||
expect(stripe.checkout.sessions.expire).not.toHaveBeenCalled();
|
||||
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
|
||||
});
|
||||
|
||||
it("lässt den alten Hold im Zweifel stehen, wenn Stripe nicht antwortet", async () => {
|
||||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||||
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||||
]);
|
||||
vi.mocked(stripe.checkout.sessions.retrieve).mockRejectedValue(new Error("Timeout"));
|
||||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
|
||||
consoleWarn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ vi.mock("@/src/lib/prisma", () => ({
|
|||
course: { findUnique: vi.fn() },
|
||||
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), count: vi.fn() },
|
||||
courseWaitlistEntry: { deleteMany: vi.fn() },
|
||||
courseSpotHold: { deleteMany: vi.fn(), updateMany: vi.fn() },
|
||||
business: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
|
@ -53,8 +54,16 @@ import { releaseDiscountReservation } from "@/src/modules/settings/lib/discount-
|
|||
|
||||
// Die Datenbank-Transaktion der Kapazitätsprüfung.
|
||||
const tx = {
|
||||
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), count: vi.fn(), upsert: vi.fn() },
|
||||
courseBooking: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
course: { findUnique: vi.fn() },
|
||||
room: { findUnique: vi.fn() },
|
||||
courseSpotHold: { findMany: vi.fn() },
|
||||
};
|
||||
|
||||
function request() {
|
||||
|
|
@ -112,6 +121,9 @@ beforeEach(() => {
|
|||
tx.courseBooking.count.mockResolvedValue(0);
|
||||
tx.courseBooking.upsert.mockResolvedValue({ id: "booking-1" });
|
||||
tx.course.findUnique.mockResolvedValue({ maxParticipants: 10 });
|
||||
tx.courseBooking.findMany.mockResolvedValue([]);
|
||||
tx.courseSpotHold.findMany.mockResolvedValue([]);
|
||||
tx.room.findUnique.mockResolvedValue(null);
|
||||
|
||||
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
|
||||
application_fee_amount: 50,
|
||||
|
|
@ -298,3 +310,97 @@ describe("Stripe-Webhook – nie bezahlt und Gebühren", () => {
|
|||
expect(recordStripeFeeForCharge).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stripe-Webhook – Platzwahl", () => {
|
||||
const reformer = (id: string, label: string, x: number) => ({
|
||||
id,
|
||||
type: "reformer",
|
||||
x,
|
||||
y: 1.5,
|
||||
rotation: 0,
|
||||
label,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tx.course.findUnique.mockResolvedValue({
|
||||
maxParticipants: 10,
|
||||
spotBooking: true,
|
||||
room: "room-1",
|
||||
});
|
||||
tx.room.findUnique.mockResolvedValue({
|
||||
floorPlan: {
|
||||
width: 8,
|
||||
depth: 6,
|
||||
items: [reformer("spot-1", "1", 1), reformer("spot-2", "2", 2)],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const spotSession = (type = "checkout.session.completed", paymentStatus = "paid") =>
|
||||
sessionEvent(type, {
|
||||
account: "acct_studio",
|
||||
paymentStatus,
|
||||
metadata: { spotId: "spot-1", spotHoldId: "hold-1" },
|
||||
});
|
||||
|
||||
it("schreibt den gewählten Platz an die Buchung und gibt den Hold frei", async () => {
|
||||
await deliver(spotSession(), "whsec_connect");
|
||||
|
||||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||||
paymentStatus: "paid",
|
||||
spotId: "spot-1",
|
||||
spotLabel: "Reformer 1",
|
||||
});
|
||||
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({ where: { id: "hold-1" } });
|
||||
});
|
||||
|
||||
it("gibt den nächsten freien Platz, wenn der Wunschplatz inzwischen bezahlt ist", async () => {
|
||||
tx.courseBooking.findMany.mockResolvedValue([{ spotId: "spot-1" }]);
|
||||
|
||||
await deliver(spotSession(), "whsec_connect");
|
||||
|
||||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||||
paymentStatus: "paid",
|
||||
spotId: "spot-2",
|
||||
spotLabel: "Reformer 2",
|
||||
});
|
||||
expect(refundStripePayment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("erstattet wie bei einem vollen Kurs, wenn kein Platz mehr frei ist", async () => {
|
||||
tx.courseBooking.count.mockResolvedValue(2);
|
||||
tx.courseBooking.findMany.mockResolvedValue([{ spotId: "spot-1" }, { spotId: "spot-2" }]);
|
||||
|
||||
await deliver(spotSession(), "whsec_connect");
|
||||
|
||||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||||
paymentStatus: "failed",
|
||||
});
|
||||
expect(refundStripePayment).toHaveBeenCalledWith({
|
||||
paymentIntentId: "pi_1",
|
||||
stripeAccountId: "acct_studio",
|
||||
refundApplicationFee: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("hält den Platz bei SEPA, bis die Zahlung ankommt", async () => {
|
||||
await deliver(spotSession("checkout.session.completed", "unpaid"), "whsec_connect");
|
||||
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
const call = vi.mocked(prisma.courseSpotHold.updateMany).mock.calls[0][0] as {
|
||||
where: { id: string };
|
||||
data: { expiresAt: Date; status: string };
|
||||
};
|
||||
expect(call.where).toEqual({ id: "hold-1" });
|
||||
// Ein neuer Checkout derselben Person darf diesen Platz nicht freigeben.
|
||||
expect(call.data.status).toBe("awaiting_payment");
|
||||
const days = (call.data.expiresAt.getTime() - Date.now()) / 86_400_000;
|
||||
expect(days).toBeGreaterThan(13);
|
||||
});
|
||||
|
||||
it("gibt den Platz frei, wenn die Session abläuft", async () => {
|
||||
await deliver(spotSession("checkout.session.expired", "unpaid"), "whsec_connect");
|
||||
|
||||
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({ where: { id: "hold-1" } });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
193
tests/courses/course-spots.test.ts
Normal file
193
tests/courses/course-spots.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
assignSpot,
|
||||
extendSpotHold,
|
||||
getCourseSpots,
|
||||
getHeldSpotIds,
|
||||
holdSpot,
|
||||
spotHolderKey,
|
||||
} from "@/src/modules/courses/lib/course-spots";
|
||||
import type { FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
|
||||
|
||||
const db = {
|
||||
room: { findUnique: vi.fn() },
|
||||
courseBooking: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
courseSpotHold: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const spot = (id: string, label: string): FloorPlanItem => ({
|
||||
id,
|
||||
type: "reformer",
|
||||
x: 1,
|
||||
y: 1.5,
|
||||
rotation: 0,
|
||||
label,
|
||||
});
|
||||
const spots = [spot("s1", "1"), spot("s2", "2"), spot("s3", "3")];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.courseBooking.findMany.mockResolvedValue([]);
|
||||
db.courseBooking.findFirst.mockResolvedValue(null);
|
||||
db.courseSpotHold.findMany.mockResolvedValue([]);
|
||||
db.courseSpotHold.deleteMany.mockResolvedValue({ count: 0 });
|
||||
db.courseSpotHold.create.mockResolvedValue({ id: "hold-1" });
|
||||
});
|
||||
|
||||
describe("getCourseSpots", () => {
|
||||
it("liest den Raum gar nicht erst, wenn der Kurs keine Platzwahl hat", async () => {
|
||||
expect(await getCourseSpots(db as never, { spotBooking: false, room: "room-1" })).toBeNull();
|
||||
expect(db.room.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("behandelt einen Raum ohne Geräte wie einen Kurs ohne Platzwahl", async () => {
|
||||
db.room.findUnique.mockResolvedValue({ floorPlan: null });
|
||||
expect(await getCourseSpots(db as never, { spotBooking: true, room: "room-1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("liefert die buchbaren Geräte des Raums", async () => {
|
||||
db.room.findUnique.mockResolvedValue({
|
||||
floorPlan: { width: 8, depth: 6, items: [spot("s2", "2"), spot("s1", "1")] },
|
||||
});
|
||||
const result = await getCourseSpots(db as never, { spotBooking: true, room: "room-1" });
|
||||
expect(result?.map((s) => s.id)).toEqual(["s1", "s2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignSpot", () => {
|
||||
it("gibt den Wunschplatz, wenn er frei ist", async () => {
|
||||
const result = await assignSpot(db as never, {
|
||||
courseId: "c1",
|
||||
spots,
|
||||
requestedSpotId: "s2",
|
||||
holdId: "hold-1",
|
||||
});
|
||||
expect(result).toEqual({ spotId: "s2", spotLabel: "Reformer 2", changed: false });
|
||||
});
|
||||
|
||||
it("weicht auf einen freien Platz aus, den niemand gerade im Checkout hält", async () => {
|
||||
db.courseBooking.findMany.mockResolvedValue([{ spotId: "s1" }]);
|
||||
db.courseSpotHold.findMany.mockResolvedValue([{ spotId: "s2" }]);
|
||||
|
||||
const result = await assignSpot(db as never, {
|
||||
courseId: "c1",
|
||||
spots,
|
||||
requestedSpotId: "s1",
|
||||
holdId: "hold-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ spotId: "s3", spotLabel: "Reformer 3", changed: true });
|
||||
// Der eigene Hold zählt nicht als "von jemand anderem gehalten".
|
||||
expect(db.courseSpotHold.findMany.mock.calls[0][0].where.id).toEqual({ not: "hold-1" });
|
||||
});
|
||||
|
||||
it("nimmt notfalls einen gehaltenen Platz - bezahlt schlägt Checkout", async () => {
|
||||
db.courseBooking.findMany.mockResolvedValue([{ spotId: "s1" }, { spotId: "s3" }]);
|
||||
db.courseSpotHold.findMany.mockResolvedValue([{ spotId: "s2" }]);
|
||||
|
||||
const result = await assignSpot(db as never, {
|
||||
courseId: "c1",
|
||||
spots,
|
||||
requestedSpotId: "s1",
|
||||
holdId: null,
|
||||
});
|
||||
expect(result?.spotId).toBe("s2");
|
||||
});
|
||||
|
||||
it("liefert null, wenn alle Plätze bezahlt sind", async () => {
|
||||
db.courseBooking.findMany.mockResolvedValue(spots.map((s) => ({ spotId: s.id })));
|
||||
const result = await assignSpot(db as never, {
|
||||
courseId: "c1",
|
||||
spots,
|
||||
requestedSpotId: "s1",
|
||||
holdId: null,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("weist bei alten Sessions ohne Wunschplatz den ersten freien zu", async () => {
|
||||
const result = await assignSpot(db as never, {
|
||||
courseId: "c1",
|
||||
spots,
|
||||
requestedSpotId: null,
|
||||
holdId: null,
|
||||
});
|
||||
expect(result).toEqual({ spotId: "s1", spotLabel: "Reformer 1", changed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("holdSpot", () => {
|
||||
const params = {
|
||||
courseId: "c1",
|
||||
spotId: "s1",
|
||||
holderKey: spotHolderKey({ userId: "u1" }),
|
||||
expiresAt: new Date(Date.now() + 30 * 60_000),
|
||||
};
|
||||
|
||||
it("räumt nur abgelaufene Holds weg - eigene Holds anderer Checkouts bleiben", async () => {
|
||||
const result = await holdSpot(db as never, params);
|
||||
|
||||
expect(result).toEqual({ held: true, holdId: "hold-1" });
|
||||
const where = db.courseSpotHold.deleteMany.mock.calls[0][0].where;
|
||||
expect(where).toEqual({ courseId: "c1", expiresAt: { lte: expect.any(Date) } });
|
||||
});
|
||||
|
||||
it("hält keinen Platz, der schon bezahlt ist", async () => {
|
||||
db.courseBooking.findFirst.mockResolvedValue({ id: "b1" });
|
||||
const result = await holdSpot(db as never, params);
|
||||
expect(result.held).toBe(false);
|
||||
expect(db.courseSpotHold.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verliert gegen einen gleichzeitigen Checkout für denselben Platz", async () => {
|
||||
db.courseSpotHold.create.mockRejectedValue(
|
||||
Object.assign(new Error("Unique constraint failed"), { code: "P2002" })
|
||||
);
|
||||
db.courseSpotHold.findUnique.mockResolvedValue({ holderKey: "user:jemand-anderes" });
|
||||
const result = await holdSpot(db as never, params);
|
||||
expect(result).toMatchObject({
|
||||
held: false,
|
||||
reason: expect.stringContaining("jemand anderem"),
|
||||
});
|
||||
});
|
||||
|
||||
it("sagt der Person, dass ihre eigene Zahlung für den Platz noch läuft", async () => {
|
||||
db.courseSpotHold.create.mockRejectedValue(
|
||||
Object.assign(new Error("Unique constraint failed"), { code: "P2002" })
|
||||
);
|
||||
db.courseSpotHold.findUnique.mockResolvedValue({ holderKey: "user:u1" });
|
||||
const result = await holdSpot(db as never, params);
|
||||
expect(result).toMatchObject({ held: false, reason: expect.stringContaining("Ihre Zahlung") });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hold-Status", () => {
|
||||
it("markiert einen verlängerten Hold als abgeschlossenen Checkout", async () => {
|
||||
const until = new Date(Date.now() + 14 * 86_400_000);
|
||||
await extendSpotHold(db as never, "hold-1", until);
|
||||
expect(db.courseSpotHold.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "hold-1" },
|
||||
data: { expiresAt: until, status: "awaiting_payment" },
|
||||
});
|
||||
});
|
||||
|
||||
it("blendet für die Person nur ihre offenen Checkouts aus", async () => {
|
||||
await getHeldSpotIds(db as never, "c1", { exceptHolderKey: "user:u1" });
|
||||
expect(db.courseSpotHold.findMany.mock.calls[0][0].where.NOT).toEqual({
|
||||
holderKey: "user:u1",
|
||||
status: "checkout",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("spotHolderKey", () => {
|
||||
it("normalisiert Gast-E-Mails, damit derselbe Gast seinen alten Hold ersetzt", () => {
|
||||
expect(spotHolderKey({ guestEmail: " Gast@Example.com " })).toBe("guest:gast@example.com");
|
||||
});
|
||||
});
|
||||
122
tests/rooms/floor-plan.test.ts
Normal file
122
tests/rooms/floor-plan.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
bookableSpots,
|
||||
findFreePosition,
|
||||
itemFootprint,
|
||||
nextLabel,
|
||||
parseFloorPlan,
|
||||
placeInRoom,
|
||||
spotDisplayName,
|
||||
type FloorPlan,
|
||||
type FloorPlanItem,
|
||||
} from "@/src/modules/rooms/lib/floor-plan";
|
||||
|
||||
let idCounter = 0;
|
||||
const item = (overrides: Partial<FloorPlanItem> = {}): FloorPlanItem => ({
|
||||
id: `item-${++idCounter}`,
|
||||
type: "reformer",
|
||||
x: 1,
|
||||
y: 2,
|
||||
rotation: 0,
|
||||
label: "1",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const plan = (items: FloorPlanItem[]): FloorPlan => ({ width: 8, depth: 6, items });
|
||||
|
||||
describe("parseFloorPlan", () => {
|
||||
it("liefert null für Räume ohne oder mit kaputtem Grundriss", () => {
|
||||
expect(parseFloorPlan(null)).toBeNull();
|
||||
expect(parseFloorPlan({})).toBeNull();
|
||||
expect(parseFloorPlan({ width: 8, depth: 6, items: [{ id: "x", type: "sofa" }] })).toBeNull();
|
||||
});
|
||||
|
||||
it("lehnt doppelte Platznummern innerhalb eines Gerätetyps ab", () => {
|
||||
const duplicate = plan([item({ label: "1" }), item({ label: "1" })]);
|
||||
expect(parseFloorPlan(duplicate)).toBeNull();
|
||||
});
|
||||
|
||||
it("erlaubt dieselbe Nummer bei verschiedenen Gerätetypen und Deko ohne Nummer", () => {
|
||||
const mixed = plan([
|
||||
item({ type: "reformer", label: "1" }),
|
||||
item({ type: "mat", label: "1" }),
|
||||
item({ type: "mirror", label: "" }),
|
||||
]);
|
||||
expect(parseFloorPlan(mixed)?.items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("verlangt bei buchbaren Geräten eine Nummer", () => {
|
||||
expect(parseFloorPlan(plan([item({ label: "" })]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bookableSpots", () => {
|
||||
it("sortiert nach Gerätetyp und Nummer und lässt Deko weg", () => {
|
||||
const spots = bookableSpots(
|
||||
plan([
|
||||
item({ type: "mat", label: "1" }),
|
||||
item({ type: "reformer", label: "10" }),
|
||||
item({ type: "trainer", label: "" }),
|
||||
item({ type: "reformer", label: "2" }),
|
||||
])
|
||||
);
|
||||
expect(spots.map(spotDisplayName)).toEqual(["Reformer 2", "Reformer 10", "Matte 1"]);
|
||||
});
|
||||
|
||||
it("liefert für keinen Grundriss eine leere Liste", () => {
|
||||
expect(bookableSpots(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextLabel", () => {
|
||||
it("füllt Lücken in der Nummerierung pro Gerätetyp", () => {
|
||||
const items = [
|
||||
item({ label: "1" }),
|
||||
item({ label: "2" }),
|
||||
item({ label: "4" }),
|
||||
item({ type: "mat", label: "3" }),
|
||||
];
|
||||
expect(nextLabel(items, "reformer")).toBe("3");
|
||||
expect(nextLabel(items, "mat")).toBe("1");
|
||||
});
|
||||
|
||||
it("vergibt an Deko keine Nummer", () => {
|
||||
expect(nextLabel([], "door")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeInRoom", () => {
|
||||
it("hält ein Gerät vollständig innerhalb der Wände", () => {
|
||||
// Reformer ungedreht: 0,7 m breit, 2,4 m lang
|
||||
expect(placeInRoom(item(), { x: -3, y: 100 }, plan([]))).toEqual({ x: 0.35, y: 4.8 });
|
||||
});
|
||||
|
||||
it("berücksichtigt die Drehung beim Einpassen", () => {
|
||||
const turned = item({ rotation: 90 });
|
||||
expect(itemFootprint(turned)).toEqual({ w: 2.4, h: 0.7 });
|
||||
expect(placeInRoom(turned, { x: 0, y: 0 }, plan([]))).toEqual({ x: 1.2, y: 0.35 });
|
||||
});
|
||||
|
||||
it("rastet auf 10 cm ein, ohne Gleitkomma-Reste", () => {
|
||||
const pos = placeInRoom(item(), { x: 2.34, y: 3.26 }, plan([]));
|
||||
expect(pos).toEqual({ x: 2.3, y: 3.3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("findFreePosition", () => {
|
||||
it("reiht neue Geräte nebeneinander auf, statt sie zu stapeln", () => {
|
||||
const room = plan([]);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const pos = findFreePosition(room, { type: "reformer", rotation: 0 });
|
||||
room.items.push(item({ ...pos, label: String(i + 1) }));
|
||||
}
|
||||
for (const a of room.items) {
|
||||
for (const b of room.items) {
|
||||
if (a === b) continue;
|
||||
const overlapX = Math.abs(a.x - b.x) < 0.7;
|
||||
const overlapY = Math.abs(a.y - b.y) < 2.4;
|
||||
expect(overlapX && overlapY).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
133
tests/rooms/room-actions.test.ts
Normal file
133
tests/rooms/room-actions.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
prisma: {
|
||||
room: { findUnique: vi.fn(), update: vi.fn() },
|
||||
courseBooking: { findMany: vi.fn() },
|
||||
course: { findMany: vi.fn(), updateMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/src/lib/auth/getUser", () => ({
|
||||
getUserData: vi.fn(),
|
||||
isManager: (u: { role: string }) => u?.role === "manager",
|
||||
isEmployee: (u: { role: string }) => u?.role === "employee",
|
||||
getEffectiveManagerId: (u: { role: string; id: string }) => u.id,
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("@/src/modules/courses/lib/public-courses-cache", () => ({
|
||||
refreshPublicCourses: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getUserData } from "@/src/lib/auth/getUser";
|
||||
import { updateRoom } from "@/src/modules/rooms/actions/room-actions";
|
||||
import { refreshPublicCourses } from "@/src/modules/courses/lib/public-courses-cache";
|
||||
import type { FloorPlan, FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
|
||||
|
||||
const reformer = (id: string, label: string, x: number): FloorPlanItem => ({
|
||||
id,
|
||||
type: "reformer",
|
||||
x,
|
||||
y: 1.5,
|
||||
rotation: 0,
|
||||
label,
|
||||
});
|
||||
|
||||
const plan = (items: FloorPlanItem[]): FloorPlan => ({ width: 8, depth: 6, items });
|
||||
|
||||
const threeReformers = plan([
|
||||
reformer("r1", "1", 1),
|
||||
reformer("r2", "2", 2),
|
||||
reformer("r3", "3", 3),
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getUserData).mockResolvedValue({ id: "mgr-1", role: "manager" } as never);
|
||||
vi.mocked(prisma.room.findUnique).mockResolvedValue({
|
||||
id: "room-1",
|
||||
createdBy: "mgr-1",
|
||||
floorPlan: threeReformers,
|
||||
} as never);
|
||||
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.course.findMany).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("updateRoom – gebuchte Plätze", () => {
|
||||
it("verhindert das Entfernen eines Geräts mit kommender Buchung", async () => {
|
||||
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([{ spotId: "r3" }] as never);
|
||||
|
||||
const result = await updateRoom("room-1", {
|
||||
name: "Reformer-Raum",
|
||||
floorPlan: plan([reformer("r1", "1", 1), reformer("r2", "2", 2)]),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ success: false });
|
||||
expect(result.error).toContain("Reformer 3");
|
||||
expect(prisma.room.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verhindert das Umnummerieren eines gebuchten Geräts", async () => {
|
||||
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([{ spotId: "r1" }] as never);
|
||||
|
||||
const result = await updateRoom("room-1", {
|
||||
name: "Reformer-Raum",
|
||||
floorPlan: plan([reformer("r1", "9", 1), reformer("r2", "2", 2), reformer("r3", "3", 3)]),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(prisma.room.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("erlaubt das Verschieben gebuchter Geräte, ohne Buchungen zu prüfen", async () => {
|
||||
const moved = plan([reformer("r1", "1", 5), reformer("r2", "2", 6), reformer("r3", "3", 7)]);
|
||||
|
||||
const result = await updateRoom("room-1", { name: "Reformer-Raum", floorPlan: moved });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prisma.courseBooking.findMany).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(prisma.room.update).mock.calls[0][0].data.floorPlan).toEqual(moved);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateRoom – Teilnehmerzahl", () => {
|
||||
it("senkt die Teilnehmerzahl kommender Platzwahl-Kurse auf die neue Platzanzahl", async () => {
|
||||
vi.mocked(prisma.course.findMany).mockResolvedValue([
|
||||
{ id: "c1", businessId: "biz-1", isStandingOrder: false },
|
||||
{ id: "c2", businessId: "biz-1", isStandingOrder: false },
|
||||
] as never);
|
||||
|
||||
const result = await updateRoom("room-1", {
|
||||
name: "Reformer-Raum",
|
||||
floorPlan: plan([reformer("r1", "1", 1), reformer("r2", "2", 2)]),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain("2 kommende Kurse");
|
||||
expect(vi.mocked(prisma.course.findMany).mock.calls[0][0]!.where).toMatchObject({
|
||||
room: "room-1",
|
||||
spotBooking: true,
|
||||
maxParticipants: { gt: 2 },
|
||||
});
|
||||
expect(prisma.course.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["c1", "c2"] } },
|
||||
data: { maxParticipants: 2 },
|
||||
});
|
||||
expect(refreshPublicCourses).toHaveBeenCalledWith("biz-1", "biz-1");
|
||||
});
|
||||
|
||||
it("lässt die Teilnehmerzahl in Ruhe, wenn der Grundriss ganz entfernt wird", async () => {
|
||||
vi.mocked(prisma.room.findUnique).mockResolvedValue({
|
||||
id: "room-1",
|
||||
createdBy: "mgr-1",
|
||||
floorPlan: null,
|
||||
} as never);
|
||||
|
||||
const result = await updateRoom("room-1", { name: "Matten-Raum", floorPlan: null });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prisma.course.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue