mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +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
|
createdBy String // Manager ID who created the course
|
||||||
businessId String?
|
businessId String?
|
||||||
coverImage String?
|
coverImage String?
|
||||||
|
// Kunden wählen beim Buchen ein Gerät aus dem Grundriss des Raums. Wirkt nur,
|
||||||
|
// solange der Raum buchbare Geräte hat - sonst wird ganz normal ohne Platz gebucht.
|
||||||
|
spotBooking Boolean @default(false)
|
||||||
business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull)
|
business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull)
|
||||||
bookings CourseBooking[]
|
bookings CourseBooking[]
|
||||||
waitlistEntries CourseWaitlistEntry[]
|
waitlistEntries CourseWaitlistEntry[]
|
||||||
|
spotHolds CourseSpotHold[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|
@ -190,6 +194,10 @@ model Room {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
createdBy String // Manager ID who created the room
|
createdBy String // Manager ID who created the room
|
||||||
|
// Grundriss mit Geräten: { width, depth, items: [{ id, type, x, y, rotation, label }] }.
|
||||||
|
// Format und Gerätekatalog siehe src/modules/rooms/lib/floor-plan.ts. null = kein Grundriss.
|
||||||
|
// Die item-IDs sind stabil und stehen als CourseBooking.spotId an Buchungen.
|
||||||
|
floorPlan Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|
@ -226,6 +234,8 @@ model CourseBooking {
|
||||||
cancellationReason String?
|
cancellationReason String?
|
||||||
discountCodeId String? // Gesetzt, sobald bei der Buchung ein gültiger Rabattcode eingelöst wurde
|
discountCodeId String? // Gesetzt, sobald bei der Buchung ein gültiger Rabattcode eingelöst wurde
|
||||||
discountAmount Int? // Tatsächlich gewährter Rabatt in Cents (Snapshot, unabhängig von späteren Code-Änderungen)
|
discountAmount Int? // Tatsächlich gewährter Rabatt in Cents (Snapshot, unabhängig von späteren Code-Änderungen)
|
||||||
|
spotId String? // Gerät aus dem Raum-Grundriss (Room.floorPlan.items[].id), nur bei Kursen mit Platzwahl. Belegt ist ein Platz, solange die Buchung "paid" ist.
|
||||||
|
spotLabel String? // Anzeigename des Platzes als Momentaufnahme, z.B. "Reformer 3" - bleibt lesbar, auch wenn der Grundriss sich später ändert
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull)
|
guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull)
|
||||||
|
|
@ -238,6 +248,31 @@ model CourseBooking {
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([guestLeadId])
|
@@index([guestLeadId])
|
||||||
@@index([discountCodeId])
|
@@index([discountCodeId])
|
||||||
|
@@index([spotId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hält einen Platz frei, während der Kunde im Stripe-Checkout bezahlt - analog
|
||||||
|
// zur DiscountRedemption-Reservierung. Ohne sie könnten zwei Kunden gleichzeitig
|
||||||
|
// mit demselben Reformer zur Kasse gehen. Entsteht beim Checkout-Start, wird im
|
||||||
|
// Webhook nach der Zahlung gelöscht (dann belegt die Buchung den Platz) oder bei
|
||||||
|
// abgelaufener Session freigegeben. Abgelaufene Holds (expiresAt < jetzt) zählen
|
||||||
|
// nicht und werden beim nächsten Reservieren aufgeräumt.
|
||||||
|
// holderKey = "user:<id>" oder "guest:<email>": pro Person und Kurs läuft höchstens
|
||||||
|
// ein offener Checkout - wer zurück geht und neu wählt, beendet den alten (siehe
|
||||||
|
// Checkout-Route). Holds abgeschlossener Checkouts bleiben dabei unangetastet.
|
||||||
|
model CourseSpotHold {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
courseId String
|
||||||
|
spotId String
|
||||||
|
holderKey String
|
||||||
|
expiresAt DateTime
|
||||||
|
stripeSessionId String? // Checkout-Session, für die der Platz gehalten wird. null, solange sie angelegt wird.
|
||||||
|
status String @default("checkout") // "checkout" (Session offen) | "awaiting_payment" (Checkout abgeschlossen, SEPA-Zahlung unterwegs)
|
||||||
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([courseId, spotId])
|
||||||
|
@@index([courseId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bewusst nur für registrierte User (kein guestEmail-Feld): ein
|
// Bewusst nur für registrierte User (kein guestEmail-Feld): ein
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,16 @@ import {
|
||||||
releaseDiscountReservation,
|
releaseDiscountReservation,
|
||||||
calculateDiscountedPriceCents,
|
calculateDiscountedPriceCents,
|
||||||
} from "@/src/modules/settings/lib/discount-validation";
|
} from "@/src/modules/settings/lib/discount-validation";
|
||||||
|
import {
|
||||||
|
attachSessionToHold,
|
||||||
|
getCourseSpots,
|
||||||
|
getOpenCheckoutHolds,
|
||||||
|
holdSpot,
|
||||||
|
releaseSpotHold,
|
||||||
|
spotHolderKey,
|
||||||
|
SPOT_HOLD_MINUTES,
|
||||||
|
} from "@/src/modules/courses/lib/course-spots";
|
||||||
|
import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt die Checkout-Session im Branding des Studios. Lehnt Stripe das
|
* Erstellt die Checkout-Session im Branding des Studios. Lehnt Stripe das
|
||||||
|
|
@ -40,6 +50,40 @@ async function createBrandedCheckoutSession(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pro Person und Kurs läuft höchstens ein offener Checkout mit Platz. Startet
|
||||||
|
* jemand neu (zurück aus dem Checkout, zweiter Tab), wird die alte Session bei
|
||||||
|
* Stripe beendet und ihr Platz freigegeben - so kann sie auch nicht später
|
||||||
|
* noch bezahlt werden.
|
||||||
|
*
|
||||||
|
* Freigegeben wird nur, was Stripe nachweislich als offen meldet. Ist die alte
|
||||||
|
* Session schon abgeschlossen (bezahlt, Webhook noch unterwegs), behält sie
|
||||||
|
* ihren Platz. Im Zweifel (Stripe nicht erreichbar) bleibt der Hold stehen und
|
||||||
|
* läuft von selbst ab.
|
||||||
|
*/
|
||||||
|
async function endOpenCheckouts(courseId: string, holderKey: string, stripeAccountId: string) {
|
||||||
|
const options = { stripeAccount: stripeAccountId };
|
||||||
|
for (const hold of await getOpenCheckoutHolds(prisma, courseId, holderKey)) {
|
||||||
|
// Ohne Session-ID wurde die Session nie fertig angelegt - nichts zu beenden.
|
||||||
|
if (!hold.stripeSessionId) {
|
||||||
|
await releaseSpotHold(prisma, hold.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const old = await stripe.checkout.sessions.retrieve(hold.stripeSessionId, {}, options);
|
||||||
|
if (old.status === "complete") continue;
|
||||||
|
if (old.status === "open") await stripe.checkout.sessions.expire(old.id, {}, options);
|
||||||
|
await releaseSpotHold(prisma, hold.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Alte Checkout-Session ${hold.stripeSessionId} konnte nicht beendet werden, ` +
|
||||||
|
`Platz-Hold ${hold.id} bleibt bis zum Ablauf bestehen:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
|
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
|
||||||
// Best-effort only — in multi-instance/serverless deployments each instance has
|
// Best-effort only — in multi-instance/serverless deployments each instance has
|
||||||
// its own Map, so a determined attacker can bypass it. The main purpose is to
|
// its own Map, so a determined attacker can bypass it. The main purpose is to
|
||||||
|
|
@ -79,6 +123,8 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
*
|
*
|
||||||
* Auth-Flow → Body: { courseId: string }
|
* Auth-Flow → Body: { courseId: string }
|
||||||
* Guest-Flow → Body: { courseId: string; guestEmail: string; guestName?: string }
|
* Guest-Flow → Body: { courseId: string; guestEmail: string; guestName?: string }
|
||||||
|
* Kurse mit Platzwahl brauchen zusätzlich { spotId } - der Platz wird für die
|
||||||
|
* Dauer der Session freigehalten (siehe course-spots.ts).
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -102,8 +148,9 @@ export async function POST(request: NextRequest) {
|
||||||
guestEmail?: string;
|
guestEmail?: string;
|
||||||
guestName?: string;
|
guestName?: string;
|
||||||
discountCode?: string;
|
discountCode?: string;
|
||||||
|
spotId?: string;
|
||||||
};
|
};
|
||||||
const { courseId, guestEmail, guestName, discountCode } = body;
|
const { courseId, guestEmail, guestName, discountCode, spotId } = body;
|
||||||
|
|
||||||
if (!courseId) {
|
if (!courseId) {
|
||||||
return NextResponse.json({ error: "courseId fehlt" }, { status: 400 });
|
return NextResponse.json({ error: "courseId fehlt" }, { status: 400 });
|
||||||
|
|
@ -280,51 +327,119 @@ export async function POST(request: NextRequest) {
|
||||||
reservationToken = reservation.reservationToken;
|
reservationToken = reservation.reservationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixe Servicegebühr, die der Kunde ZUSÄTZLICH zum Kurspreis zahlt und die
|
const holderKey = spotHolderKey({
|
||||||
// als eigene Checkout-Position sichtbar ist. Der Gesamtbetrag der Charge
|
userId: isGuest ? null : userData!.id,
|
||||||
// ist damit priceCents + platformFeeCents; application_fee_amount führt
|
guestEmail: isGuest ? guestEmail : null,
|
||||||
// exakt die Gebühr an Kumasy ab. Rabattcodes mindern nur den Kurspreis,
|
});
|
||||||
// nie die Gebühr.
|
let spotHoldId: string | null = null;
|
||||||
const platformFeeCents = getPlatformFeeCents();
|
let spotLabel: string | null = null;
|
||||||
|
|
||||||
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
|
// Ab hier ist ggf. ein Rabattcode reserviert und gleich ein Platz gehalten.
|
||||||
|
// Jeder Fehler bis zur fertigen Session muss beides wieder freigeben, sonst
|
||||||
// Alles, was der Webhook später braucht, um die Buchung anzulegen. Liegt
|
// blieben sie blockiert, ohne dass je eine Buchung zustande kommt - eine
|
||||||
// an Session UND PaymentIntent, damit es auch im Stripe-Dashboard an der
|
// nie angelegte Session meldet auch kein checkout.session.expired.
|
||||||
// Zahlung sichtbar ist.
|
const releaseReservations = async () => {
|
||||||
const metadata = {
|
if (reservationToken) {
|
||||||
courseId: course.id,
|
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
|
||||||
userId: isGuest ? "" : userData!.id,
|
console.error(
|
||||||
businessId: course.business.id,
|
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagenem ` +
|
||||||
isGuest: isGuest ? "true" : "false",
|
`Checkout-Start nicht freigegeben werden:`,
|
||||||
guestEmail: isGuest ? customerEmail : "",
|
releaseError
|
||||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
);
|
||||||
discountCodeId: appliedDiscountCodeId ?? "",
|
});
|
||||||
discountAmountCents: String(discountAmountCents),
|
}
|
||||||
discountReservationToken: reservationToken ?? "",
|
if (spotHoldId) {
|
||||||
// Sagt dem Webhook, dass die Gebühr obendrauf kam. Sessions aus der Zeit
|
await releaseSpotHold(prisma, spotHoldId).catch((releaseError) => {
|
||||||
// der prozentualen Gebühr haben diesen Schlüssel nicht - eine solche
|
console.error(
|
||||||
// Session, die erst nach einem Deploy bezahlt wird, darf nicht als
|
`Platz-Hold ${spotHoldId} konnte nach fehlgeschlagenem Checkout-Start nicht ` +
|
||||||
// "obendrauf" verbucht werden.
|
`freigegeben werden:`,
|
||||||
platformFeeModel: "on_top",
|
releaseError
|
||||||
};
|
);
|
||||||
|
});
|
||||||
// Der Kunde bucht beim Studio - der Checkout trägt deshalb dessen Farben
|
}
|
||||||
// und Logo, wie die öffentliche Seite davor. Ohne eigenes Branding greifen
|
|
||||||
// die Kumasy-Farben.
|
|
||||||
const studioBranding = toEmailBranding(course.business);
|
|
||||||
const brandingSettings: Stripe.Checkout.SessionCreateParams.BrandingSettings = {
|
|
||||||
display_name: course.business.name,
|
|
||||||
background_color: studioBranding.primaryColor,
|
|
||||||
button_color: studioBranding.accentColor,
|
|
||||||
border_style: "rounded",
|
|
||||||
...(studioBranding.logoUrl && isAllowedLogoUrl(studioBranding.logoUrl)
|
|
||||||
? { logo: { type: "url", url: studioBranding.logoUrl } }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
|
// Platzwahl: erst nach dem Rabattcode, damit ein abgelehnter Code keinen
|
||||||
|
// Platz blockiert.
|
||||||
|
const spots = await getCourseSpots(prisma, course);
|
||||||
|
if (spots) {
|
||||||
|
const spot = spots.find((s) => s.id === spotId);
|
||||||
|
if (!spot) {
|
||||||
|
await releaseReservations();
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: spotId
|
||||||
|
? "Diesen Platz gibt es nicht mehr. Bitte wählen Sie einen anderen."
|
||||||
|
: "Bitte wählen Sie einen Platz aus.",
|
||||||
|
spotUnavailable: true,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await endOpenCheckouts(course.id, holderKey, course.business.stripeAccountId);
|
||||||
|
const hold = await holdSpot(prisma, {
|
||||||
|
courseId: course.id,
|
||||||
|
spotId: spot.id,
|
||||||
|
holderKey,
|
||||||
|
// Etwas länger als die Session, damit ein Kunde, der in der letzten
|
||||||
|
// Minute bezahlt, seinen Platz bis zum Webhook behält.
|
||||||
|
expiresAt: new Date(Date.now() + (SPOT_HOLD_MINUTES + 5) * 60_000),
|
||||||
|
});
|
||||||
|
if (!hold.held) {
|
||||||
|
await releaseReservations();
|
||||||
|
return NextResponse.json({ error: hold.reason, spotUnavailable: true }, { status: 409 });
|
||||||
|
}
|
||||||
|
spotHoldId = hold.holdId;
|
||||||
|
spotLabel = spotDisplayName(spot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixe Servicegebühr, die der Kunde ZUSÄTZLICH zum Kurspreis zahlt und die
|
||||||
|
// als eigene Checkout-Position sichtbar ist. Der Gesamtbetrag der Charge
|
||||||
|
// ist damit priceCents + platformFeeCents; application_fee_amount führt
|
||||||
|
// exakt die Gebühr an Kumasy ab. Rabattcodes mindern nur den Kurspreis,
|
||||||
|
// nie die Gebühr.
|
||||||
|
const platformFeeCents = getPlatformFeeCents();
|
||||||
|
|
||||||
|
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
|
||||||
|
|
||||||
|
// Alles, was der Webhook später braucht, um die Buchung anzulegen. Liegt
|
||||||
|
// an Session UND PaymentIntent, damit es auch im Stripe-Dashboard an der
|
||||||
|
// Zahlung sichtbar ist.
|
||||||
|
const metadata = {
|
||||||
|
courseId: course.id,
|
||||||
|
userId: isGuest ? "" : userData!.id,
|
||||||
|
businessId: course.business.id,
|
||||||
|
isGuest: isGuest ? "true" : "false",
|
||||||
|
guestEmail: isGuest ? customerEmail : "",
|
||||||
|
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||||
|
discountCodeId: appliedDiscountCodeId ?? "",
|
||||||
|
discountAmountCents: String(discountAmountCents),
|
||||||
|
discountReservationToken: reservationToken ?? "",
|
||||||
|
// Sagt dem Webhook, dass die Gebühr obendrauf kam. Sessions aus der Zeit
|
||||||
|
// der prozentualen Gebühr haben diesen Schlüssel nicht - eine solche
|
||||||
|
// Session, die erst nach einem Deploy bezahlt wird, darf nicht als
|
||||||
|
// "obendrauf" verbucht werden.
|
||||||
|
platformFeeModel: "on_top",
|
||||||
|
spotId: spotHoldId ? spotId! : "",
|
||||||
|
spotHoldId: spotHoldId ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Der Kunde bucht beim Studio - der Checkout trägt deshalb dessen Farben
|
||||||
|
// und Logo, wie die öffentliche Seite davor. Ohne eigenes Branding greifen
|
||||||
|
// die Kumasy-Farben.
|
||||||
|
const studioBranding = toEmailBranding(course.business);
|
||||||
|
const brandingSettings: Stripe.Checkout.SessionCreateParams.BrandingSettings = {
|
||||||
|
display_name: course.business.name,
|
||||||
|
background_color: studioBranding.primaryColor,
|
||||||
|
button_color: studioBranding.accentColor,
|
||||||
|
border_style: "rounded",
|
||||||
|
...(studioBranding.logoUrl && isAllowedLogoUrl(studioBranding.logoUrl)
|
||||||
|
? { logo: { type: "url", url: studioBranding.logoUrl } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
session = await createBrandedCheckoutSession(
|
session = await createBrandedCheckoutSession(
|
||||||
{
|
{
|
||||||
mode: "payment",
|
mode: "payment",
|
||||||
|
|
@ -337,7 +452,7 @@ export async function POST(request: NextRequest) {
|
||||||
currency: "eur",
|
currency: "eur",
|
||||||
product_data: {
|
product_data: {
|
||||||
name: course.name,
|
name: course.name,
|
||||||
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}`,
|
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}${spotLabel ? ` · Platz: ${spotLabel}` : ""}`,
|
||||||
},
|
},
|
||||||
unit_amount: priceCents,
|
unit_amount: priceCents,
|
||||||
},
|
},
|
||||||
|
|
@ -367,27 +482,30 @@ export async function POST(request: NextRequest) {
|
||||||
},
|
},
|
||||||
metadata,
|
metadata,
|
||||||
customer_email: customerEmail,
|
customer_email: customerEmail,
|
||||||
|
// Mit Platzwahl verfällt die Session, wenn der Platz-Hold endet -
|
||||||
|
// sonst könnte jemand nach Stunden einen längst freigegebenen Platz
|
||||||
|
// bezahlen. Ohne Platzwahl bleibt Stripes Standard (24 Stunden).
|
||||||
|
...(spotHoldId
|
||||||
|
? { expires_at: Math.floor(Date.now() / 1000) + SPOT_HOLD_MINUTES * 60 }
|
||||||
|
: {}),
|
||||||
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
|
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
|
||||||
},
|
},
|
||||||
brandingSettings,
|
brandingSettings,
|
||||||
course.business.stripeAccountId
|
course.business.stripeAccountId
|
||||||
);
|
);
|
||||||
} catch (stripeError) {
|
} catch (error) {
|
||||||
// Reservierung wurde bereits vor der Session-Erstellung angelegt (siehe
|
await releaseReservations();
|
||||||
// oben) - schlägt die Session-Erstellung selbst fehl, muss der belegte
|
throw error;
|
||||||
// Slot wieder freigegeben werden, sonst bliebe er dauerhaft blockiert,
|
}
|
||||||
// ohne dass je eine Buchung zustande kommt.
|
|
||||||
if (reservationToken) {
|
// Damit ein späterer Checkout derselben Person diese Session beenden kann
|
||||||
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
|
// (siehe endOpenCheckouts). Scheitert das, bleibt der Platz trotzdem bis
|
||||||
console.error(
|
// zum Ablauf gehalten - kein Grund, die fertige Session zu verwerfen.
|
||||||
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagener ` +
|
if (spotHoldId && session.id) {
|
||||||
`Stripe-Session nicht freigegeben werden:`,
|
await attachSessionToHold(prisma, spotHoldId, session.id).catch((err) =>
|
||||||
releaseError
|
console.error(`Session konnte nicht an Platz-Hold ${spotHoldId} gehängt werden:`, err)
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
throw stripeError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,14 @@ import {
|
||||||
confirmDiscountReservation,
|
confirmDiscountReservation,
|
||||||
releaseDiscountReservation,
|
releaseDiscountReservation,
|
||||||
} from "@/src/modules/settings/lib/discount-validation";
|
} from "@/src/modules/settings/lib/discount-validation";
|
||||||
|
import {
|
||||||
|
assignSpot,
|
||||||
|
extendSpotHold,
|
||||||
|
getCourseSpots,
|
||||||
|
releaseSpotHold,
|
||||||
|
DELAYED_PAYMENT_HOLD_DAYS,
|
||||||
|
type AssignedSpot,
|
||||||
|
} from "@/src/modules/courses/lib/course-spots";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
|
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
|
||||||
|
|
@ -52,7 +60,7 @@ async function findUserByEmailInsensitive(email: string) {
|
||||||
* - checkout.session.async_payment_succeeded → die verzögerte Zahlung ist
|
* - checkout.session.async_payment_succeeded → die verzögerte Zahlung ist
|
||||||
* angekommen, Buchung anlegen wie oben.
|
* angekommen, Buchung anlegen wie oben.
|
||||||
* - checkout.session.expired / async_payment_failed → nie bezahlt: Rabattcode-
|
* - checkout.session.expired / async_payment_failed → nie bezahlt: Rabattcode-
|
||||||
* Reservierung freigeben, Pending-Buchung aufräumen.
|
* Reservierung und Platz-Hold freigeben, Pending-Buchung aufräumen.
|
||||||
* - charge.updated → Stripe-Gebühr nachtragen, sobald Stripe sie kennt.
|
* - charge.updated → Stripe-Gebühr nachtragen, sobald Stripe sie kennt.
|
||||||
* - account.updated → Stripe Connect Account-Status synchronisieren.
|
* - account.updated → Stripe Connect Account-Status synchronisieren.
|
||||||
*/
|
*/
|
||||||
|
|
@ -189,15 +197,29 @@ async function handleCheckoutCompleted(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Platzwahl (siehe course-spots.ts): Wunschplatz und der Hold, der ihn
|
||||||
|
// seit dem Checkout-Start freihält. Leer bei Kursen ohne Platzwahl.
|
||||||
|
const spotRequest = {
|
||||||
|
requestedSpotId: session.metadata?.spotId || null,
|
||||||
|
holdId: session.metadata?.spotHoldId || null,
|
||||||
|
};
|
||||||
|
|
||||||
if (session.payment_status !== "paid") {
|
if (session.payment_status !== "paid") {
|
||||||
// Verzögerte Zahlungsart wie SEPA-Lastschrift: Der Kunde hat den Checkout
|
// Verzögerte Zahlungsart wie SEPA-Lastschrift: Der Kunde hat den Checkout
|
||||||
// abgeschlossen, das Geld ist aber noch unterwegs. Gebucht wird erst bei
|
// abgeschlossen, das Geld ist aber noch unterwegs. Gebucht wird erst bei
|
||||||
// checkout.session.async_payment_succeeded. Die Rabattcode-Reservierung
|
// checkout.session.async_payment_succeeded. Die Rabattcode-Reservierung
|
||||||
// bleibt bis dahin bestehen.
|
// bleibt bis dahin bestehen, der Platz-Hold wird bis dahin verlängert.
|
||||||
console.info(
|
console.info(
|
||||||
`Session ${session.id} ist noch nicht bezahlt (${session.payment_status}) - ` +
|
`Session ${session.id} ist noch nicht bezahlt (${session.payment_status}) - ` +
|
||||||
`warte auf checkout.session.async_payment_succeeded.`
|
`warte auf checkout.session.async_payment_succeeded.`
|
||||||
);
|
);
|
||||||
|
if (spotRequest.holdId) {
|
||||||
|
await extendSpotHold(
|
||||||
|
prisma,
|
||||||
|
spotRequest.holdId,
|
||||||
|
new Date(Date.now() + DELAYED_PAYMENT_HOLD_DAYS * 24 * 60 * 60_000)
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,6 +301,7 @@ async function handleCheckoutCompleted(
|
||||||
userId: registeredUser.id,
|
userId: registeredUser.id,
|
||||||
session,
|
session,
|
||||||
payment,
|
payment,
|
||||||
|
spotRequest,
|
||||||
discountCodeId,
|
discountCodeId,
|
||||||
discountAmountCents,
|
discountAmountCents,
|
||||||
discountReservationToken,
|
discountReservationToken,
|
||||||
|
|
@ -290,6 +313,7 @@ async function handleCheckoutCompleted(
|
||||||
guestName: guestName || undefined,
|
guestName: guestName || undefined,
|
||||||
session,
|
session,
|
||||||
payment,
|
payment,
|
||||||
|
spotRequest,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const userId = session.metadata?.userId;
|
const userId = session.metadata?.userId;
|
||||||
|
|
@ -328,6 +352,7 @@ async function handleCheckoutCompleted(
|
||||||
userId: registeredUser.id,
|
userId: registeredUser.id,
|
||||||
session,
|
session,
|
||||||
payment,
|
payment,
|
||||||
|
spotRequest,
|
||||||
discountCodeId,
|
discountCodeId,
|
||||||
discountAmountCents,
|
discountAmountCents,
|
||||||
discountReservationToken,
|
discountReservationToken,
|
||||||
|
|
@ -339,6 +364,7 @@ async function handleCheckoutCompleted(
|
||||||
guestName: session.metadata?.guestName || undefined,
|
guestName: session.metadata?.guestName || undefined,
|
||||||
session,
|
session,
|
||||||
payment,
|
payment,
|
||||||
|
spotRequest,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -369,6 +395,7 @@ async function handleCheckoutCompleted(
|
||||||
userId,
|
userId,
|
||||||
session,
|
session,
|
||||||
payment,
|
payment,
|
||||||
|
spotRequest,
|
||||||
discountCodeId,
|
discountCodeId,
|
||||||
discountAmountCents,
|
discountAmountCents,
|
||||||
discountReservationToken,
|
discountReservationToken,
|
||||||
|
|
@ -403,6 +430,15 @@ async function handleCheckoutCompleted(
|
||||||
|
|
||||||
if (!outcome) throw new Error("runCapacityTransaction lieferte kein Ergebnis");
|
if (!outcome) throw new Error("runCapacityTransaction lieferte kein Ergebnis");
|
||||||
|
|
||||||
|
// Ab hier hält die Buchung den Platz (oder es gibt keine) - der Hold aus dem
|
||||||
|
// Checkout-Start hat seinen Zweck erfüllt. Ein Fehler beim Aufräumen ist
|
||||||
|
// harmlos: der Hold läuft ohnehin ab.
|
||||||
|
if (spotRequest.holdId) {
|
||||||
|
await releaseSpotHold(prisma, spotRequest.holdId).catch((err) =>
|
||||||
|
console.error(`Platz-Hold ${spotRequest.holdId} konnte nicht freigegeben werden:`, err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const needsRefund = outcome.result === "capacity_exceeded" || outcome.result === "already_failed";
|
const needsRefund = outcome.result === "capacity_exceeded" || outcome.result === "already_failed";
|
||||||
|
|
||||||
let refundSucceeded = false;
|
let refundSucceeded = false;
|
||||||
|
|
@ -470,7 +506,10 @@ async function handleCheckoutCompleted(
|
||||||
const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null;
|
const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null;
|
||||||
if (recipientEmail) {
|
if (recipientEmail) {
|
||||||
if (outcome.result === "booked") {
|
if (outcome.result === "booked") {
|
||||||
await withTimeout(sendBookingConfirmation(recipientEmail, courseId, amountPaidCents), 2000);
|
await withTimeout(
|
||||||
|
sendBookingConfirmation(recipientEmail, courseId, amountPaidCents, outcome.spot ?? null),
|
||||||
|
2000
|
||||||
|
);
|
||||||
} else if (needsRefund && refundSucceeded) {
|
} else if (needsRefund && refundSucceeded) {
|
||||||
await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000);
|
await withTimeout(sendBookingRefund(recipientEmail, courseId, amountPaidCents), 2000);
|
||||||
} else if (outcome.result === "duplicate_payment" && duplicateRefundSucceeded) {
|
} else if (outcome.result === "duplicate_payment" && duplicateRefundSucceeded) {
|
||||||
|
|
@ -530,8 +569,17 @@ type CapacityOutcome = {
|
||||||
| "course_not_found"
|
| "course_not_found"
|
||||||
| "duplicate_payment";
|
| "duplicate_payment";
|
||||||
bookingId?: string;
|
bookingId?: string;
|
||||||
|
/** Zugewiesener Platz bei Kursen mit Platzwahl (nur bei "booked"). */
|
||||||
|
spot?: AssignedSpot | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SpotRequest = { requestedSpotId: string | null; holdId: string | null };
|
||||||
|
|
||||||
|
/** Spalten für den Platz einer Buchung - explizit null, damit ein Upsert alte Werte überschreibt. */
|
||||||
|
function spotColumns(spot: AssignedSpot | null) {
|
||||||
|
return { spotId: spot?.spotId ?? null, spotLabel: spot?.spotLabel ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
// Status, die eine bereits abschließend verarbeitete Buchung markieren - bei
|
// Status, die eine bereits abschließend verarbeitete Buchung markieren - bei
|
||||||
// erneuter Zustellung DERSELBEN Checkout-Session (Stripe-Retry, manuelles
|
// erneuter Zustellung DERSELBEN Checkout-Session (Stripe-Retry, manuelles
|
||||||
// Redeliver im Dashboard) darf der Webhook den Datensatz auf KEINEN Fall per
|
// Redeliver im Dashboard) darf der Webhook den Datensatz auf KEINEN Fall per
|
||||||
|
|
@ -562,6 +610,7 @@ async function runCapacityTransactionUser(params: {
|
||||||
userId: string;
|
userId: string;
|
||||||
session: Stripe.Checkout.Session;
|
session: Stripe.Checkout.Session;
|
||||||
payment: SessionPayment;
|
payment: SessionPayment;
|
||||||
|
spotRequest?: SpotRequest;
|
||||||
discountCodeId?: string | null;
|
discountCodeId?: string | null;
|
||||||
discountAmountCents?: number;
|
discountAmountCents?: number;
|
||||||
discountReservationToken?: string | null;
|
discountReservationToken?: string | null;
|
||||||
|
|
@ -572,6 +621,7 @@ async function runCapacityTransactionUser(params: {
|
||||||
userId,
|
userId,
|
||||||
session,
|
session,
|
||||||
payment,
|
payment,
|
||||||
|
spotRequest = { requestedSpotId: null, holdId: null },
|
||||||
discountCodeId = null,
|
discountCodeId = null,
|
||||||
discountAmountCents = 0,
|
discountAmountCents = 0,
|
||||||
discountReservationToken = null,
|
discountReservationToken = null,
|
||||||
|
|
@ -637,7 +687,7 @@ async function runCapacityTransactionUser(params: {
|
||||||
|
|
||||||
const course = await tx.course.findUnique({
|
const course = await tx.course.findUnique({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
select: { maxParticipants: true },
|
select: { maxParticipants: true, spotBooking: true, room: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!course) return { result: "course_not_found" as const };
|
if (!course) return { result: "course_not_found" as const };
|
||||||
|
|
@ -646,12 +696,21 @@ async function runCapacityTransactionUser(params: {
|
||||||
where: { courseId, paymentStatus: "paid" },
|
where: { courseId, paymentStatus: "paid" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (paidCount >= course.maxParticipants) {
|
// Bei Platzwahl ist der Kurs auch dann voll, wenn kein Platz mehr frei
|
||||||
|
// ist - etwa weil der Grundriss nach dem Checkout-Start verkleinert wurde.
|
||||||
|
const spots = await getCourseSpots(tx, course);
|
||||||
|
const spot =
|
||||||
|
spots && paidCount < course.maxParticipants
|
||||||
|
? await assignSpot(tx, { courseId, spots, ...spotRequest })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (paidCount >= course.maxParticipants || (spots && !spot)) {
|
||||||
const booking = await tx.courseBooking.upsert({
|
const booking = await tx.courseBooking.upsert({
|
||||||
where: { courseId_userId: { courseId, userId } },
|
where: { courseId_userId: { courseId, userId } },
|
||||||
update: {
|
update: {
|
||||||
paymentStatus: "failed",
|
paymentStatus: "failed",
|
||||||
...paymentColumns(session.id, payment),
|
...paymentColumns(session.id, payment),
|
||||||
|
...spotColumns(null),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
courseId,
|
courseId,
|
||||||
|
|
@ -668,6 +727,7 @@ async function runCapacityTransactionUser(params: {
|
||||||
update: {
|
update: {
|
||||||
paymentStatus: "paid",
|
paymentStatus: "paid",
|
||||||
...paymentColumns(session.id, payment),
|
...paymentColumns(session.id, payment),
|
||||||
|
...spotColumns(spot),
|
||||||
discountCodeId,
|
discountCodeId,
|
||||||
discountAmount: discountCodeId ? discountAmountCents : null,
|
discountAmount: discountCodeId ? discountAmountCents : null,
|
||||||
},
|
},
|
||||||
|
|
@ -676,6 +736,7 @@ async function runCapacityTransactionUser(params: {
|
||||||
userId,
|
userId,
|
||||||
paymentStatus: "paid",
|
paymentStatus: "paid",
|
||||||
...paymentColumns(session.id, payment),
|
...paymentColumns(session.id, payment),
|
||||||
|
...spotColumns(spot),
|
||||||
discountCodeId,
|
discountCodeId,
|
||||||
discountAmount: discountCodeId ? discountAmountCents : null,
|
discountAmount: discountCodeId ? discountAmountCents : null,
|
||||||
},
|
},
|
||||||
|
|
@ -697,7 +758,7 @@ async function runCapacityTransactionUser(params: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { result: "booked" as const, bookingId: booking.id };
|
return { result: "booked" as const, bookingId: booking.id, spot };
|
||||||
},
|
},
|
||||||
{ isolationLevel: "Serializable" }
|
{ isolationLevel: "Serializable" }
|
||||||
);
|
);
|
||||||
|
|
@ -714,8 +775,16 @@ async function runCapacityTransactionGuest(params: {
|
||||||
guestName?: string;
|
guestName?: string;
|
||||||
session: Stripe.Checkout.Session;
|
session: Stripe.Checkout.Session;
|
||||||
payment: SessionPayment;
|
payment: SessionPayment;
|
||||||
|
spotRequest?: SpotRequest;
|
||||||
}): Promise<CapacityOutcome> {
|
}): Promise<CapacityOutcome> {
|
||||||
const { courseId, guestEmail, guestName, session, payment } = params;
|
const {
|
||||||
|
courseId,
|
||||||
|
guestEmail,
|
||||||
|
guestName,
|
||||||
|
session,
|
||||||
|
payment,
|
||||||
|
spotRequest = { requestedSpotId: null, holdId: null },
|
||||||
|
} = params;
|
||||||
|
|
||||||
return prisma.$transaction(
|
return prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
|
|
@ -742,7 +811,7 @@ async function runCapacityTransactionGuest(params: {
|
||||||
|
|
||||||
const course = await tx.course.findUnique({
|
const course = await tx.course.findUnique({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
select: { maxParticipants: true },
|
select: { maxParticipants: true, spotBooking: true, room: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!course) return { result: "course_not_found" as const };
|
if (!course) return { result: "course_not_found" as const };
|
||||||
|
|
@ -751,10 +820,16 @@ async function runCapacityTransactionGuest(params: {
|
||||||
where: { courseId, paymentStatus: "paid" },
|
where: { courseId, paymentStatus: "paid" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const spots = await getCourseSpots(tx, course);
|
||||||
|
const spot =
|
||||||
|
spots && paidCount < course.maxParticipants
|
||||||
|
? await assignSpot(tx, { courseId, spots, ...spotRequest })
|
||||||
|
: null;
|
||||||
|
|
||||||
// GuestLead erst nach Kapazitätsprüfung anlegen — verhindert Phantom-Einträge
|
// GuestLead erst nach Kapazitätsprüfung anlegen — verhindert Phantom-Einträge
|
||||||
// bei Überbuchung. upsert by email: ein Gast bekommt genau einen Lead-Eintrag,
|
// bei Überbuchung. upsert by email: ein Gast bekommt genau einen Lead-Eintrag,
|
||||||
// auch bei mehreren Buchungen (@@unique auf email im Schema).
|
// auch bei mehreren Buchungen (@@unique auf email im Schema).
|
||||||
if (paidCount >= course.maxParticipants) {
|
if (paidCount >= course.maxParticipants || (spots && !spot)) {
|
||||||
const guestLead = await tx.guestLead.upsert({
|
const guestLead = await tx.guestLead.upsert({
|
||||||
where: { email: guestEmail },
|
where: { email: guestEmail },
|
||||||
update: {},
|
update: {},
|
||||||
|
|
@ -782,9 +857,10 @@ async function runCapacityTransactionGuest(params: {
|
||||||
guestLeadId: guestLead.id,
|
guestLeadId: guestLead.id,
|
||||||
paymentStatus: "paid",
|
paymentStatus: "paid",
|
||||||
...paymentColumns(session.id, payment),
|
...paymentColumns(session.id, payment),
|
||||||
|
...spotColumns(spot),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { result: "booked" as const, bookingId: booking.id };
|
return { result: "booked" as const, bookingId: booking.id, spot };
|
||||||
},
|
},
|
||||||
{ isolationLevel: "Serializable" }
|
{ isolationLevel: "Serializable" }
|
||||||
);
|
);
|
||||||
|
|
@ -803,7 +879,8 @@ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null
|
||||||
async function sendBookingConfirmation(
|
async function sendBookingConfirmation(
|
||||||
recipientEmail: string,
|
recipientEmail: string,
|
||||||
courseId: string,
|
courseId: string,
|
||||||
amountPaidCents: number
|
amountPaidCents: number,
|
||||||
|
spot: AssignedSpot | null
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const course = await prisma.course.findUnique({
|
const course = await prisma.course.findUnique({
|
||||||
|
|
@ -828,6 +905,8 @@ async function sendBookingConfirmation(
|
||||||
businessName: course.business.name,
|
businessName: course.business.name,
|
||||||
businessAddress: course.business.address,
|
businessAddress: course.business.address,
|
||||||
amountPaidCents,
|
amountPaidCents,
|
||||||
|
spotLabel: spot?.spotLabel ?? null,
|
||||||
|
spotChanged: spot?.changed ?? false,
|
||||||
myCoursesUrl: `${baseUrl}/courses/myCourses`,
|
myCoursesUrl: `${baseUrl}/courses/myCourses`,
|
||||||
coverImageUrl: course.coverImage,
|
coverImageUrl: course.coverImage,
|
||||||
companyName: process.env.COMPANY_NAME,
|
companyName: process.env.COMPANY_NAME,
|
||||||
|
|
@ -966,6 +1045,14 @@ async function handleCheckoutUnpaid(session: Stripe.Checkout.Session) {
|
||||||
const courseId = session.metadata?.courseId;
|
const courseId = session.metadata?.courseId;
|
||||||
const isGuest = session.metadata?.isGuest === "true";
|
const isGuest = session.metadata?.isGuest === "true";
|
||||||
const discountReservationToken = session.metadata?.discountReservationToken || null;
|
const discountReservationToken = session.metadata?.discountReservationToken || null;
|
||||||
|
const spotHoldId = session.metadata?.spotHoldId || null;
|
||||||
|
|
||||||
|
// Nie bezahlt - der Platz wird wieder frei.
|
||||||
|
if (spotHoldId) {
|
||||||
|
await releaseSpotHold(prisma, spotHoldId).catch((error) =>
|
||||||
|
console.error(`Platz-Hold ${spotHoldId} konnte nicht freigegeben werden:`, error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Gibt einen beim Checkout-Start reservierten Rabattcode-Slot wieder frei
|
// Gibt einen beim Checkout-Start reservierten Rabattcode-Slot wieder frei
|
||||||
// (siehe reserveDiscountCode), da diese Session nie bezahlt wurde. Läuft
|
// (siehe reserveDiscountCode), da diese Session nie bezahlt wurde. Läuft
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Button } from "@/src/components/ui/button";
|
||||||
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
|
import { getCourseSummaryForConfirmation } from "@/src/modules/courses/actions/booking-actions";
|
||||||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||||
import { CheckCircle2, Calendar, Clock, MapPin } from "lucide-react";
|
import { CheckCircle2, Calendar, Clock, MapPin, LayoutGrid } from "lucide-react";
|
||||||
|
|
||||||
interface SuccessPageProps {
|
interface SuccessPageProps {
|
||||||
params: Promise<{ courseId: string }>;
|
params: Promise<{ courseId: string }>;
|
||||||
|
|
@ -87,6 +87,14 @@ export default async function CourseBookingSuccessPage({ params, searchParams }:
|
||||||
<span>{course.business.address}</span>
|
<span>{course.business.address}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{result.success && result.spotLabel && (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<LayoutGrid size={15} className="text-gray-400 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Ihr Platz: <b>{result.spotLabel}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 mt-4 pt-3">
|
<div className="border-t border-gray-200 mt-4 pt-3">
|
||||||
|
|
|
||||||
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;
|
businessName: string;
|
||||||
businessAddress?: string;
|
businessAddress?: string;
|
||||||
amountPaidCents: number;
|
amountPaidCents: number;
|
||||||
|
/** Gebuchter Platz bei Kursen mit Platzwahl, z.B. "Reformer 3". */
|
||||||
|
spotLabel?: string | null;
|
||||||
|
/** true = der Wunschplatz war bei Zahlungseingang vergeben, spotLabel ist ein anderer. */
|
||||||
|
spotChanged?: boolean;
|
||||||
myCoursesUrl: string;
|
myCoursesUrl: string;
|
||||||
coverImageUrl?: string | null;
|
coverImageUrl?: string | null;
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
|
@ -49,6 +53,8 @@ export function generateBookingConfirmationEmail({
|
||||||
businessName,
|
businessName,
|
||||||
businessAddress,
|
businessAddress,
|
||||||
amountPaidCents,
|
amountPaidCents,
|
||||||
|
spotLabel,
|
||||||
|
spotChanged = false,
|
||||||
myCoursesUrl,
|
myCoursesUrl,
|
||||||
coverImageUrl,
|
coverImageUrl,
|
||||||
companyName = "Ihr Unternehmen",
|
companyName = "Ihr Unternehmen",
|
||||||
|
|
@ -60,6 +66,10 @@ export function generateBookingConfirmationEmail({
|
||||||
const safeBusinessAddress = businessAddress ? escapeHtml(businessAddress) : "";
|
const safeBusinessAddress = businessAddress ? escapeHtml(businessAddress) : "";
|
||||||
const safeTimeFrom = escapeHtml(timeFrom);
|
const safeTimeFrom = escapeHtml(timeFrom);
|
||||||
const safeTimeTo = escapeHtml(timeTo);
|
const safeTimeTo = escapeHtml(timeTo);
|
||||||
|
const safeSpotLabel = spotLabel ? escapeHtml(spotLabel) : "";
|
||||||
|
const spotChangedNote = spotChanged
|
||||||
|
? "Ihr Wunschplatz war bei Zahlungseingang leider schon vergeben – wir haben Ihnen diesen Platz reserviert."
|
||||||
|
: "";
|
||||||
const safeMyCoursesUrl = sanitizeUrl(myCoursesUrl);
|
const safeMyCoursesUrl = sanitizeUrl(myCoursesUrl);
|
||||||
const safeCoverImageUrl = coverImageUrl ? sanitizeUrl(coverImageUrl) : null;
|
const safeCoverImageUrl = coverImageUrl ? sanitizeUrl(coverImageUrl) : null;
|
||||||
|
|
||||||
|
|
@ -95,6 +105,18 @@ export function generateBookingConfirmationEmail({
|
||||||
{ label: "Anbieter", valueHtml: safeBusinessName },
|
{ label: "Anbieter", valueHtml: safeBusinessName },
|
||||||
{ label: "Termin", valueHtml: `${formattedDate}<br />${safeTimeFrom} – ${safeTimeTo} Uhr` },
|
{ label: "Termin", valueHtml: `${formattedDate}<br />${safeTimeFrom} – ${safeTimeTo} Uhr` },
|
||||||
...(safeBusinessAddress ? [{ label: "Ort", valueHtml: safeBusinessAddress }] : []),
|
...(safeBusinessAddress ? [{ label: "Ort", valueHtml: safeBusinessAddress }] : []),
|
||||||
|
...(safeSpotLabel
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Ihr Platz",
|
||||||
|
valueHtml: `<strong>${safeSpotLabel}</strong>${
|
||||||
|
spotChangedNote
|
||||||
|
? `<br /><span style="color: #666666; font-size: 13px;">${spotChangedNote}</span>`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|
@ -138,7 +160,7 @@ vielen Dank für Ihre Buchung. Ihre Zahlung wurde erfolgreich verarbeitet.
|
||||||
Kurs: ${courseName}
|
Kurs: ${courseName}
|
||||||
Anbieter: ${businessName}
|
Anbieter: ${businessName}
|
||||||
Termin: ${formattedDate}, ${timeFrom} – ${timeTo} Uhr
|
Termin: ${formattedDate}, ${timeFrom} – ${timeTo} Uhr
|
||||||
${businessAddress ? `Ort: ${businessAddress}\n` : ""}Bezahlt: ${formattedPrice}
|
${businessAddress ? `Ort: ${businessAddress}\n` : ""}${spotLabel ? `Ihr Platz: ${spotLabel}\n` : ""}${spotChangedNote ? `${spotChangedNote}\n` : ""}Bezahlt: ${formattedPrice}
|
||||||
|
|
||||||
Wir wünschen Ihnen viel Spaß im Kurs!
|
Wir wünschen Ihnen viel Spaß im Kurs!
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import {
|
||||||
validateDiscountCode,
|
validateDiscountCode,
|
||||||
calculateDiscountedPriceCents,
|
calculateDiscountedPriceCents,
|
||||||
} from "@/src/modules/settings/lib/discount-validation";
|
} from "@/src/modules/settings/lib/discount-validation";
|
||||||
|
import { bookableSpots, parseFloorPlan, type FloorPlan } from "@/src/modules/rooms/lib/floor-plan";
|
||||||
|
import { getBookedSpotIds, getHeldSpotIds, spotHolderKey } from "../lib/course-spots";
|
||||||
|
|
||||||
export type UserBooking = {
|
export type UserBooking = {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
|
@ -43,6 +45,8 @@ export type UserBooking = {
|
||||||
trainers: string[];
|
trainers: string[];
|
||||||
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
||||||
paymentStatus: string;
|
paymentStatus: string;
|
||||||
|
/** Gebuchter Platz bei Kursen mit Platzwahl, z.B. "Reformer 3". */
|
||||||
|
spotLabel: string | null;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
businessName: string;
|
businessName: string;
|
||||||
/** Kontaktdaten des Standorts für den Detail-Dialog (Ticket: Email + Ort anzeigen). */
|
/** Kontaktdaten des Standorts für den Detail-Dialog (Ticket: Email + Ort anzeigen). */
|
||||||
|
|
@ -68,6 +72,7 @@ export async function getUserBookings(): Promise<
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
paymentStatus: true,
|
paymentStatus: true,
|
||||||
|
spotLabel: true,
|
||||||
course: {
|
course: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
@ -158,6 +163,7 @@ export async function getUserBookings(): Promise<
|
||||||
trainers: b.course.trainers,
|
trainers: b.course.trainers,
|
||||||
trainersMap,
|
trainersMap,
|
||||||
paymentStatus: b.paymentStatus,
|
paymentStatus: b.paymentStatus,
|
||||||
|
spotLabel: b.spotLabel,
|
||||||
isPast: courseStart < now,
|
isPast: courseStart < now,
|
||||||
businessName: b.course.business?.name ?? branding.companyName,
|
businessName: b.course.business?.name ?? branding.companyName,
|
||||||
businessAddress: b.course.business?.address ?? null,
|
businessAddress: b.course.business?.address ?? null,
|
||||||
|
|
@ -315,27 +321,85 @@ export async function getCourseSummaryForConfirmation(courseId: string, sessionI
|
||||||
// die Anzeige auf den regulären Kurspreis zurück.
|
// die Anzeige auf den regulären Kurspreis zurück.
|
||||||
let paidPrice = course.price;
|
let paidPrice = course.price;
|
||||||
let discountAmount: number | null = null;
|
let discountAmount: number | null = null;
|
||||||
|
let spotLabel: string | null = null;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
// findFirst statt findUnique mit stripeSessionId allein: eine URL, die
|
// findFirst statt findUnique mit stripeSessionId allein: eine URL, die
|
||||||
// courseId von Kurs B mit der session_id von Kurs A kombiniert, würde
|
// courseId von Kurs B mit der session_id von Kurs A kombiniert, würde
|
||||||
// sonst Kurs B mit den Zahlungsdaten von Kurs A anzeigen.
|
// sonst Kurs B mit den Zahlungsdaten von Kurs A anzeigen.
|
||||||
const booking = await prisma.courseBooking.findFirst({
|
const booking = await prisma.courseBooking.findFirst({
|
||||||
where: { stripeSessionId: sessionId, courseId },
|
where: { stripeSessionId: sessionId, courseId },
|
||||||
select: { amountPaid: true, discountAmount: true },
|
select: { amountPaid: true, discountAmount: true, spotLabel: true },
|
||||||
});
|
});
|
||||||
if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) {
|
if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) {
|
||||||
paidPrice = booking.amountPaid / 100;
|
paidPrice = booking.amountPaid / 100;
|
||||||
discountAmount = booking.discountAmount !== null ? booking.discountAmount / 100 : null;
|
discountAmount = booking.discountAmount !== null ? booking.discountAmount / 100 : null;
|
||||||
}
|
}
|
||||||
|
spotLabel = booking?.spotLabel ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true as const, course, paidPrice, discountAmount };
|
return { success: true as const, course, paidPrice, discountAmount, spotLabel };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching course summary:", error);
|
console.error("Error fetching course summary:", error);
|
||||||
return { success: false as const, error: "Fehler beim Laden des Kurses" };
|
return { success: false as const, error: "Fehler beim Laden des Kurses" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CourseSpotMap = {
|
||||||
|
floorPlan: FloorPlan;
|
||||||
|
/** Bezahlt gebucht oder gerade von jemand anderem im Checkout gehalten. */
|
||||||
|
takenSpotIds: string[];
|
||||||
|
/** Platz der eigenen bezahlten Buchung des eingeloggten Kunden. */
|
||||||
|
mySpotId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grundriss und Live-Belegung für die Platzwahl im Buchungsdialog. Bewusst
|
||||||
|
* ungecacht (die Kursliste ist 30 s gecacht) - wer den Dialog öffnet, soll
|
||||||
|
* keinen Platz wählen, der seit Sekunden weg ist. null = der Kurs wird ohne
|
||||||
|
* Platzwahl gebucht. Verrät nicht, WER auf einem Platz liegt.
|
||||||
|
*/
|
||||||
|
export async function getCourseSpotMap(courseId: string): Promise<CourseSpotMap | null> {
|
||||||
|
try {
|
||||||
|
const course = await prisma.course.findFirst({
|
||||||
|
where: { id: courseId, status: "published", business: { isPublic: true } },
|
||||||
|
select: { spotBooking: true, room: true },
|
||||||
|
});
|
||||||
|
if (!course?.spotBooking || !course.room) return null;
|
||||||
|
|
||||||
|
const room = await prisma.room.findUnique({
|
||||||
|
where: { id: course.room },
|
||||||
|
select: { floorPlan: true },
|
||||||
|
});
|
||||||
|
const floorPlan = parseFloorPlan(room?.floorPlan);
|
||||||
|
if (!floorPlan || bookableSpots(floorPlan).length === 0) return null;
|
||||||
|
|
||||||
|
const userData = await getUserData();
|
||||||
|
const userId = userData && isUser(userData) ? userData.id : null;
|
||||||
|
|
||||||
|
const [booked, held, myBooking] = await Promise.all([
|
||||||
|
getBookedSpotIds(prisma, courseId),
|
||||||
|
getHeldSpotIds(
|
||||||
|
prisma,
|
||||||
|
courseId,
|
||||||
|
userId ? { exceptHolderKey: spotHolderKey({ userId }) } : {}
|
||||||
|
),
|
||||||
|
userId
|
||||||
|
? prisma.courseBooking.findFirst({
|
||||||
|
where: { courseId, userId, paymentStatus: "paid" },
|
||||||
|
select: { spotId: true },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mySpotId = myBooking?.spotId ?? null;
|
||||||
|
const takenSpotIds = [...new Set([...booked, ...held])].filter((id) => id !== mySpotId);
|
||||||
|
return { floorPlan, takenSpotIds, mySpotId };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching course spot map:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBusinessBySlug(slug: string) {
|
export async function getBusinessBySlug(slug: string) {
|
||||||
const fetch = unstable_cache(
|
const fetch = unstable_cache(
|
||||||
async () => {
|
async () => {
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,47 @@ import {
|
||||||
TooManyInstancesError,
|
TooManyInstancesError,
|
||||||
type FrequencyType,
|
type FrequencyType,
|
||||||
} from "../utils/generate-instances";
|
} from "../utils/generate-instances";
|
||||||
|
import { bookableSpots, parseFloorPlan } from "@/src/modules/rooms/lib/floor-plan";
|
||||||
|
|
||||||
type StandingOrderScope = "this" | "this_and_following" | "all";
|
type StandingOrderScope = "this" | "this_and_following" | "all";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platzwahl braucht einen Raum mit Geräten, und es dürfen nicht mehr
|
||||||
|
* Teilnehmer zugelassen sein, als es Plätze gibt - sonst bekäme der letzte
|
||||||
|
* Kunde zwar eine Zahlung abgebucht, aber keinen Platz. Gibt eine
|
||||||
|
* Fehlermeldung zurück oder null, wenn alles passt.
|
||||||
|
*
|
||||||
|
* Liest nur Räume des eigenen Managers - sonst ließe sich mit einer fremden
|
||||||
|
* Raum-ID dessen Platzanzahl auslesen.
|
||||||
|
*/
|
||||||
|
async function validateSpotBooking(
|
||||||
|
data: { spotBooking?: boolean; room?: string; maxParticipants?: number },
|
||||||
|
managerId: string
|
||||||
|
): Promise<{ error: string; field: "room" | "maxParticipants" } | null> {
|
||||||
|
if (!data.spotBooking) return null;
|
||||||
|
if (!data.room) {
|
||||||
|
return { error: "Für die Platzwahl braucht der Kurs einen Raum", field: "room" };
|
||||||
|
}
|
||||||
|
const room = await prisma.room.findFirst({
|
||||||
|
where: { id: data.room, createdBy: managerId },
|
||||||
|
select: { floorPlan: true },
|
||||||
|
});
|
||||||
|
const spotCount = bookableSpots(parseFloorPlan(room?.floorPlan)).length;
|
||||||
|
if (spotCount === 0) {
|
||||||
|
return {
|
||||||
|
error: "Dieser Raum hat keinen Grundriss mit Geräten - Platzwahl ist hier nicht möglich",
|
||||||
|
field: "room",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data.maxParticipants && data.maxParticipants > spotCount) {
|
||||||
|
return {
|
||||||
|
error: `Höchstens ${spotCount} Teilnehmer - so viele Plätze hat der Raum`,
|
||||||
|
field: "maxParticipants",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt einen neuen Kurs in der Datenbank
|
* Erstellt einen neuen Kurs in der Datenbank
|
||||||
*
|
*
|
||||||
|
|
@ -75,6 +113,15 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
|
||||||
if (!creatorId)
|
if (!creatorId)
|
||||||
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
|
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
|
||||||
|
|
||||||
|
const spotError = await validateSpotBooking(validatedData, creatorId);
|
||||||
|
if (spotError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: spotError.error,
|
||||||
|
fieldErrors: { [spotError.field]: [spotError.error] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Schritt 6: Kurs erstellen — bei Dauerauftrag Template + Instanzen, sonst einzelner Kurs
|
// Schritt 6: Kurs erstellen — bei Dauerauftrag Template + Instanzen, sonst einzelner Kurs
|
||||||
const commonFields = {
|
const commonFields = {
|
||||||
name: validatedData.name || "",
|
name: validatedData.name || "",
|
||||||
|
|
@ -88,6 +135,7 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
|
||||||
coverImage: validatedData.coverImage ?? null,
|
coverImage: validatedData.coverImage ?? null,
|
||||||
maxParticipants: validatedData.maxParticipants || 0,
|
maxParticipants: validatedData.maxParticipants || 0,
|
||||||
price: validatedData.price ?? 0,
|
price: validatedData.price ?? 0,
|
||||||
|
spotBooking: validatedData.spotBooking ?? false,
|
||||||
frequency: validatedData.frequency || null,
|
frequency: validatedData.frequency || null,
|
||||||
weekdays: validatedData.weekdays || [],
|
weekdays: validatedData.weekdays || [],
|
||||||
weekdayTimings: validatedData.weekdayTimings
|
weekdayTimings: validatedData.weekdayTimings
|
||||||
|
|
@ -458,6 +506,15 @@ export async function updateCourse(
|
||||||
|
|
||||||
const validatedData = validation.data;
|
const validatedData = validation.data;
|
||||||
|
|
||||||
|
const spotError = await validateSpotBooking(validatedData, managerId);
|
||||||
|
if (spotError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: spotError.error,
|
||||||
|
fieldErrors: { [spotError.field]: [spotError.error] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Felder, die auf Instanzen angewendet werden (ohne date/timeFrom/timeTo)
|
// Felder, die auf Instanzen angewendet werden (ohne date/timeFrom/timeTo)
|
||||||
const sharedUpdateFields = {
|
const sharedUpdateFields = {
|
||||||
name: validatedData.name || "",
|
name: validatedData.name || "",
|
||||||
|
|
@ -469,6 +526,7 @@ export async function updateCourse(
|
||||||
coverImage: validatedData.coverImage ?? null,
|
coverImage: validatedData.coverImage ?? null,
|
||||||
maxParticipants: validatedData.maxParticipants || 0,
|
maxParticipants: validatedData.maxParticipants || 0,
|
||||||
price: validatedData.price ?? 0,
|
price: validatedData.price ?? 0,
|
||||||
|
spotBooking: validatedData.spotBooking ?? false,
|
||||||
status,
|
status,
|
||||||
...(validatedData.businessId !== undefined
|
...(validatedData.businessId !== undefined
|
||||||
? { businessId: validatedData.businessId || null }
|
? { businessId: validatedData.businessId || null }
|
||||||
|
|
|
||||||
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,
|
weekdayTimings: weekdayTimingsSchema,
|
||||||
endDate: isoDateOptional,
|
endDate: isoDateOptional,
|
||||||
businessId: z.string().optional(),
|
businessId: z.string().optional(),
|
||||||
|
spotBooking: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
|
|
@ -92,6 +93,7 @@ export const publishedCourseSchema = z
|
||||||
weekdayTimings: weekdayTimingsSchema,
|
weekdayTimings: weekdayTimingsSchema,
|
||||||
endDate: isoDateOptional,
|
endDate: isoDateOptional,
|
||||||
businessId: z.string().optional(),
|
businessId: z.string().optional(),
|
||||||
|
spotBooking: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export type Course = {
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
businessId: string | null;
|
businessId: string | null;
|
||||||
coverImage: string | null;
|
coverImage: string | null;
|
||||||
|
spotBooking: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,26 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useSyncExternalStore } from "react";
|
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Clock, MapPin, Users, Mail, Phone, DoorOpen, Info, X } from "lucide-react";
|
import { Clock, MapPin, Users, Mail, Phone, DoorOpen, Info, X, LayoutGrid } from "lucide-react";
|
||||||
import { Button } from "@/src/components/ui/button";
|
import { Button } from "@/src/components/ui/button";
|
||||||
import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
|
import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
|
||||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { checkUserBookingStatus, previewDiscountCode } from "../../actions/booking-actions";
|
import {
|
||||||
|
checkUserBookingStatus,
|
||||||
|
getCourseSpotMap,
|
||||||
|
previewDiscountCode,
|
||||||
|
type CourseSpotMap,
|
||||||
|
} from "../../actions/booking-actions";
|
||||||
import { joinWaitlistAsUser } from "../../actions/waitlist-actions";
|
import { joinWaitlistAsUser } from "../../actions/waitlist-actions";
|
||||||
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { COURSE_LEVELS } from "../../lib/course-levels";
|
import { COURSE_LEVELS } from "../../lib/course-levels";
|
||||||
import { formatEuro } from "./course-schedule-shared";
|
import { formatEuro } from "./course-schedule-shared";
|
||||||
|
import { SpotPicker } from "@/src/modules/rooms/ui/components/floor-plan/SpotPicker";
|
||||||
|
import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
|
||||||
|
|
||||||
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
|
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
|
||||||
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben
|
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben
|
||||||
|
|
@ -78,6 +85,10 @@ export interface PublicCourse {
|
||||||
roomName?: string | null;
|
roomName?: string | null;
|
||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
trainerProfiles?: TrainerProfile[];
|
trainerProfiles?: TrainerProfile[];
|
||||||
|
/** Kunden wählen beim Buchen einen Platz im Grundriss des Raums. */
|
||||||
|
spotBooking?: boolean;
|
||||||
|
/** Gebuchter Platz - nur in der Ansicht einer bestehenden Buchung. */
|
||||||
|
spotLabel?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" | "lg" }) => {
|
const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" | "lg" }) => {
|
||||||
|
|
@ -165,6 +176,13 @@ export const BookingDialog = ({
|
||||||
code: string;
|
code: string;
|
||||||
discountedPrice: number;
|
discountedPrice: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
// Platzwahl: Grundriss + Belegung, live geladen beim Öffnen. Bleibt null bei
|
||||||
|
// Kursen ohne Platzwahl - dann läuft die Buchung wie bisher.
|
||||||
|
const [spotMap, setSpotMap] = useState<CourseSpotMap | null>(null);
|
||||||
|
const [isLoadingSpots, setIsLoadingSpots] = useState(false);
|
||||||
|
const [selectedSpotId, setSelectedSpotId] = useState<string | null>(null);
|
||||||
|
const [highlightPicker, setHighlightPicker] = useState(false);
|
||||||
|
const spotPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const isAdminView = paymentStatus !== undefined;
|
const isAdminView = paymentStatus !== undefined;
|
||||||
|
|
||||||
|
|
@ -215,6 +233,52 @@ export const BookingDialog = ({
|
||||||
};
|
};
|
||||||
}, [open, course.id, isAdminView]);
|
}, [open, course.id, isAdminView]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || isAdminView || !course.spotBooking) return;
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const loadSpots = async () => {
|
||||||
|
setIsLoadingSpots(true);
|
||||||
|
try {
|
||||||
|
const map = await getCourseSpotMap(course.id);
|
||||||
|
if (!active) return;
|
||||||
|
setSpotMap(map);
|
||||||
|
// Eine Auswahl aus einem früheren Öffnen gilt nur, solange der Platz frei ist.
|
||||||
|
setSelectedSpotId((prev) =>
|
||||||
|
prev &&
|
||||||
|
map?.floorPlan.items.some((i) => i.id === prev) &&
|
||||||
|
!map.takenSpotIds.includes(prev)
|
||||||
|
? prev
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
if (active) setSpotMap(null);
|
||||||
|
} finally {
|
||||||
|
if (active) setIsLoadingSpots(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSpots();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [open, course.id, course.spotBooking, isAdminView]);
|
||||||
|
|
||||||
|
const spotMode = !isAdminView && spotMap !== null;
|
||||||
|
const selectedSpot = spotMode
|
||||||
|
? (spotMap.floorPlan.items.find((i) => i.id === selectedSpotId) ?? null)
|
||||||
|
: null;
|
||||||
|
const mySpot = spotMode
|
||||||
|
? (spotMap.floorPlan.items.find((i) => i.id === spotMap.mySpotId) ?? null)
|
||||||
|
: null;
|
||||||
|
const needsSpot = spotMode && !selectedSpot;
|
||||||
|
|
||||||
|
const showSpotPicker = () => {
|
||||||
|
spotPickerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
setHighlightPicker(true);
|
||||||
|
window.setTimeout(() => setHighlightPicker(false), 1200);
|
||||||
|
};
|
||||||
|
|
||||||
// Setzt den "Wird gebucht"-Zustand zurück, wenn die Seite nach einem
|
// Setzt den "Wird gebucht"-Zustand zurück, wenn die Seite nach einem
|
||||||
// Stripe-Checkout-Abbruch (Zurück-Navigation) aus dem bfcache wiederhergestellt wird.
|
// Stripe-Checkout-Abbruch (Zurück-Navigation) aus dem bfcache wiederhergestellt wird.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -251,6 +315,7 @@ export const BookingDialog = ({
|
||||||
try {
|
try {
|
||||||
const body: Record<string, string> = { courseId: course.id };
|
const body: Record<string, string> = { courseId: course.id };
|
||||||
if (appliedDiscount) body.discountCode = appliedDiscount.code;
|
if (appliedDiscount) body.discountCode = appliedDiscount.code;
|
||||||
|
if (selectedSpot) body.spotId = selectedSpot.id;
|
||||||
if (opts?.asGuest) {
|
if (opts?.asGuest) {
|
||||||
const normalizedEmail = guestEmail.trim().toLowerCase();
|
const normalizedEmail = guestEmail.trim().toLowerCase();
|
||||||
if (!EMAIL_RE.test(normalizedEmail)) {
|
if (!EMAIL_RE.test(normalizedEmail)) {
|
||||||
|
|
@ -268,7 +333,12 @@ export const BookingDialog = ({
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
let data: { url?: string; error?: string; isGuestRequired?: boolean } = {};
|
let data: {
|
||||||
|
url?: string;
|
||||||
|
error?: string;
|
||||||
|
isGuestRequired?: boolean;
|
||||||
|
spotUnavailable?: boolean;
|
||||||
|
} = {};
|
||||||
try {
|
try {
|
||||||
data = (await response.json()) as typeof data;
|
data = (await response.json()) as typeof data;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -288,6 +358,17 @@ export const BookingDialog = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Platz inzwischen weg (oder Grundriss geändert): Belegung neu laden,
|
||||||
|
// damit der Kunde direkt einen anderen wählen kann.
|
||||||
|
if (data.spotUnavailable) {
|
||||||
|
toast.error(data.error || "Dieser Platz ist nicht mehr frei.");
|
||||||
|
setSelectedSpotId(null);
|
||||||
|
setSpotMap(await getCourseSpotMap(course.id));
|
||||||
|
setIsSubmitting(false);
|
||||||
|
showSpotPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok || !data.url) {
|
if (!response.ok || !data.url) {
|
||||||
toast.error(data.error || "Ein Fehler ist aufgetreten");
|
toast.error(data.error || "Ein Fehler ist aufgetreten");
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -407,6 +488,15 @@ export const BookingDialog = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAdminView && course.spotLabel && (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<LayoutGrid size={15} className="text-gray-400 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<b>Platz:</b> {course.spotLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{business?.address && (
|
{business?.address && (
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<MapPin size={15} className="text-gray-400 shrink-0" />
|
<MapPin size={15} className="text-gray-400 shrink-0" />
|
||||||
|
|
@ -442,6 +532,40 @@ export const BookingDialog = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{spotMode && (isBooked ? mySpot : !isFull) && (
|
||||||
|
<div
|
||||||
|
ref={spotPickerRef}
|
||||||
|
className={`flex flex-col gap-3 rounded-xl border p-3 sm:p-4 transition-colors ${
|
||||||
|
highlightPicker ? "border-gray-900 bg-gray-50" : "border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<LayoutGrid size={15} className="text-gray-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
{isBooked ? (
|
||||||
|
<b>Ihr Platz: {spotDisplayName(mySpot!)}</b>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<b>Platz wählen</b>
|
||||||
|
<p className="text-gray-500 mt-0.5">
|
||||||
|
Tippen Sie auf ein freies Gerät – es ist für Sie reserviert, sobald die
|
||||||
|
Zahlung durch ist.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SpotPicker
|
||||||
|
floorPlan={spotMap.floorPlan}
|
||||||
|
takenSpotIds={spotMap.takenSpotIds}
|
||||||
|
selectedSpotId={selectedSpotId}
|
||||||
|
mySpotId={spotMap.mySpotId}
|
||||||
|
onSelect={isBooked ? undefined : setSelectedSpotId}
|
||||||
|
accentColor={accentColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{course.description && (
|
{course.description && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<Info size={15} className="text-gray-400 shrink-0 mt-0.5" />
|
<Info size={15} className="text-gray-400 shrink-0 mt-0.5" />
|
||||||
|
|
@ -505,6 +629,17 @@ export const BookingDialog = ({
|
||||||
Kurs + {formatEuro(serviceFee)} Servicegebühr
|
Kurs + {formatEuro(serviceFee)} Servicegebühr
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{spotMode && !isBooked && !isFull && (
|
||||||
|
<span className="text-xs text-gray-700 mt-0.5">
|
||||||
|
{selectedSpot ? (
|
||||||
|
<>
|
||||||
|
Ihr Platz: <b>{spotDisplayName(selectedSpot)}</b>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Noch kein Platz gewählt"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
|
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
|
||||||
|
|
@ -543,7 +678,7 @@ export const BookingDialog = ({
|
||||||
>
|
>
|
||||||
Schließen
|
Schließen
|
||||||
</Button>
|
</Button>
|
||||||
{isCheckingStatus ? (
|
{isCheckingStatus || isLoadingSpots ? (
|
||||||
<Button disabled className="flex-1 sm:flex-initial">
|
<Button disabled className="flex-1 sm:flex-initial">
|
||||||
Lade…
|
Lade…
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -570,12 +705,12 @@ export const BookingDialog = ({
|
||||||
// Gäste buchen über das Formular darunter - ein zweiter
|
// Gäste buchen über das Formular darunter - ein zweiter
|
||||||
// "Buchen"-Button daneben wäre nur verwirrend.
|
// "Buchen"-Button daneben wäre nur verwirrend.
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleBooking()}
|
onClick={() => (needsSpot ? showSpotPicker() : handleBooking())}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="flex-1 sm:flex-initial"
|
className="flex-1 sm:flex-initial"
|
||||||
style={accentButtonStyle}
|
style={accentButtonStyle}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Wird gebucht…" : "Buchen"}
|
{isSubmitting ? "Wird gebucht…" : needsSpot ? "Platz wählen" : "Buchen"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -629,12 +764,16 @@ export const BookingDialog = ({
|
||||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
|
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleBooking({ asGuest: true })}
|
onClick={() => (needsSpot ? showSpotPicker() : handleBooking({ asGuest: true }))}
|
||||||
disabled={isSubmitting || !EMAIL_RE.test(guestEmail.trim().toLowerCase())}
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
isLoadingSpots ||
|
||||||
|
(!needsSpot && !EMAIL_RE.test(guestEmail.trim().toLowerCase()))
|
||||||
|
}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={accentButtonStyle}
|
style={accentButtonStyle}
|
||||||
>
|
>
|
||||||
{isSubmitting ? "Wird gebucht…" : "Als Gast buchen"}
|
{isSubmitting ? "Wird gebucht…" : needsSpot ? "Platz wählen" : "Als Gast buchen"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ function toPublicCourse(b: UserBooking): PublicCourse {
|
||||||
currentParticipants: b.currentParticipants,
|
currentParticipants: b.currentParticipants,
|
||||||
description: b.description,
|
description: b.description,
|
||||||
roomName: b.roomName,
|
roomName: b.roomName,
|
||||||
|
spotLabel: b.spotLabel,
|
||||||
coverImage: b.coverImage ?? undefined,
|
coverImage: b.coverImage ?? undefined,
|
||||||
trainerProfiles: b.trainerProfiles,
|
trainerProfiles: b.trainerProfiles,
|
||||||
};
|
};
|
||||||
|
|
@ -182,9 +183,12 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
|
||||||
{/* Datum + Raum */}
|
{/* Datum + Raum */}
|
||||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||||
<span>{dateFormatted}</span>
|
<span>{dateFormatted}</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1 min-w-0">
|
||||||
<MapPin size={13} className="shrink-0" />
|
<MapPin size={13} className="shrink-0" />
|
||||||
{booking.roomName}
|
<span className="truncate">
|
||||||
|
{booking.roomName}
|
||||||
|
{booking.spotLabel && ` · ${booking.spotLabel}`}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
FileText,
|
FileText,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Check,
|
Check,
|
||||||
|
LayoutGrid,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
||||||
|
|
@ -49,6 +50,7 @@ import {
|
||||||
StandingOrderScopeDialog,
|
StandingOrderScopeDialog,
|
||||||
type StandingOrderScope,
|
type StandingOrderScope,
|
||||||
} from "../components/StandingOrderScopeDialog";
|
} from "../components/StandingOrderScopeDialog";
|
||||||
|
import { bookableSpots, parseFloorPlan } from "@/src/modules/rooms/lib/floor-plan";
|
||||||
|
|
||||||
const LEVELS = [
|
const LEVELS = [
|
||||||
{ value: "any", label: "Jedes Niveau" },
|
{ value: "any", label: "Jedes Niveau" },
|
||||||
|
|
@ -128,6 +130,9 @@ const CourseCreateView = ({
|
||||||
const [maxParticipants, setMaxParticipants] = useState(
|
const [maxParticipants, setMaxParticipants] = useState(
|
||||||
isEdit ? initialData!.maxParticipants?.toString() || "" : ""
|
isEdit ? initialData!.maxParticipants?.toString() || "" : ""
|
||||||
);
|
);
|
||||||
|
// Neue Kurse in einem Raum mit Geräten bekommen standardmäßig Platzwahl -
|
||||||
|
// der Schalter erscheint ohnehin nur, wenn der Raum einen Grundriss hat.
|
||||||
|
const [spotBooking, setSpotBooking] = useState(isEdit ? initialData!.spotBooking : true);
|
||||||
const [isStandingOrder, setIsStandingOrder] = useState(
|
const [isStandingOrder, setIsStandingOrder] = useState(
|
||||||
isEdit ? initialData!.isStandingOrder || false : false
|
isEdit ? initialData!.isStandingOrder || false : false
|
||||||
);
|
);
|
||||||
|
|
@ -169,7 +174,9 @@ const CourseCreateView = ({
|
||||||
const [isPriceFocused, setIsPriceFocused] = useState(false);
|
const [isPriceFocused, setIsPriceFocused] = useState(false);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [rooms, setRooms] = useState<Array<{ value: string; label: string }>>([]);
|
const [rooms, setRooms] = useState<Array<{ value: string; label: string; spotCount: number }>>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
||||||
const [trainers, setTrainers] = useState<Array<{ value: string; label: string }>>([]);
|
const [trainers, setTrainers] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
const [isLoadingTrainers, setIsLoadingTrainers] = useState(true);
|
const [isLoadingTrainers, setIsLoadingTrainers] = useState(true);
|
||||||
|
|
@ -186,6 +193,21 @@ const CourseCreateView = ({
|
||||||
const isStandingOrderRelated =
|
const isStandingOrderRelated =
|
||||||
isEdit && !!(initialData?.parentCourseId || initialData?.isStandingOrder);
|
isEdit && !!(initialData?.parentCourseId || initialData?.isStandingOrder);
|
||||||
|
|
||||||
|
const roomSpotCount = rooms.find((r) => r.value === selectedRoom)?.spotCount ?? 0;
|
||||||
|
const spotBookingActive = spotBooking && roomSpotCount > 0;
|
||||||
|
|
||||||
|
// Mit Platzwahl gibt es höchstens so viele Teilnehmer wie Plätze - beim
|
||||||
|
// Einschalten oder Raumwechsel die Zahl gleich passend setzen.
|
||||||
|
const fitParticipantsToSpots = (spots: number) => {
|
||||||
|
const current = parseInt(maxParticipants);
|
||||||
|
if (spots > 0 && (isNaN(current) || current > spots)) setMaxParticipants(String(spots));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomSelect = (roomId: string) => {
|
||||||
|
setSelectedRoom(roomId);
|
||||||
|
if (spotBooking) fitParticipantsToSpots(rooms.find((r) => r.value === roomId)?.spotCount ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
deleteDialogOpen,
|
deleteDialogOpen,
|
||||||
setDeleteDialogOpen,
|
setDeleteDialogOpen,
|
||||||
|
|
@ -225,6 +247,7 @@ const CourseCreateView = ({
|
||||||
const roomOptions = result.rooms.map((room) => ({
|
const roomOptions = result.rooms.map((room) => ({
|
||||||
value: room.id,
|
value: room.id,
|
||||||
label: room.name,
|
label: room.name,
|
||||||
|
spotCount: bookableSpots(parseFloorPlan(room.floorPlan)).length,
|
||||||
}));
|
}));
|
||||||
setRooms(roomOptions);
|
setRooms(roomOptions);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -291,6 +314,8 @@ const CourseCreateView = ({
|
||||||
newErrors.maxParticipants = "Mindestens 1 Teilnehmer erforderlich";
|
newErrors.maxParticipants = "Mindestens 1 Teilnehmer erforderlich";
|
||||||
} else if (maxPart > 100) {
|
} else if (maxPart > 100) {
|
||||||
newErrors.maxParticipants = "Maximal 100 Teilnehmer erlaubt";
|
newErrors.maxParticipants = "Maximal 100 Teilnehmer erlaubt";
|
||||||
|
} else if (spotBookingActive && maxPart > roomSpotCount) {
|
||||||
|
newErrors.maxParticipants = `Höchstens ${roomSpotCount} – so viele Plätze hat der Raum`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceValue = parseFloat(price);
|
const priceValue = parseFloat(price);
|
||||||
|
|
@ -369,6 +394,9 @@ const CourseCreateView = ({
|
||||||
weekdayTimings: weekdays.cleanedTimings,
|
weekdayTimings: weekdays.cleanedTimings,
|
||||||
endDate: endDate || undefined,
|
endDate: endDate || undefined,
|
||||||
businessId: selectedBusinessId || undefined,
|
businessId: selectedBusinessId || undefined,
|
||||||
|
// Solange die Räume laden, ist die Platzanzahl unbekannt - dann den
|
||||||
|
// bisherigen Wert behalten statt die Platzwahl versehentlich abzuschalten.
|
||||||
|
spotBooking: isLoadingRooms ? spotBooking : spotBookingActive,
|
||||||
});
|
});
|
||||||
|
|
||||||
const doSubmit = async (
|
const doSubmit = async (
|
||||||
|
|
@ -756,7 +784,7 @@ const CourseCreateView = ({
|
||||||
<GenericDropdown
|
<GenericDropdown
|
||||||
label="Raum"
|
label="Raum"
|
||||||
selected={selectedRoom}
|
selected={selectedRoom}
|
||||||
onSelect={setSelectedRoom}
|
onSelect={handleRoomSelect}
|
||||||
options={rooms}
|
options={rooms}
|
||||||
error={errors.room}
|
error={errors.room}
|
||||||
disabled={isLoadingRooms}
|
disabled={isLoadingRooms}
|
||||||
|
|
@ -772,6 +800,42 @@ const CourseCreateView = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{roomSpotCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const next = !spotBooking;
|
||||||
|
setSpotBooking(next);
|
||||||
|
if (next) fitParticipantsToSpots(roomSpotCount);
|
||||||
|
}}
|
||||||
|
aria-pressed={spotBooking}
|
||||||
|
className={`flex items-center gap-3 w-full rounded-xl border p-4 text-left transition-all cursor-pointer ${
|
||||||
|
spotBooking
|
||||||
|
? "border-[#c9a84c]/50 bg-[#c9a84c]/5"
|
||||||
|
: "border-gray-200 bg-white/40 hover:border-gray-300 hover:bg-white/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center size-8 rounded-full border-2 transition-all shrink-0 ${
|
||||||
|
spotBooking ? "border-[#c9a84c] bg-[#c9a84c]" : "border-gray-300 bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{spotBooking && <Check className="size-4 text-white" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900">Platzwahl beim Buchen</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
Kunden wählen ihr Gerät im Grundriss – {roomSpotCount}{" "}
|
||||||
|
{roomSpotCount === 1 ? "Platz" : "Plätze"} in diesem Raum
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LayoutGrid
|
||||||
|
className={`ml-auto size-4 shrink-0 transition-colors ${
|
||||||
|
spotBooking ? "text-[#c9a84c]" : "text-gray-300"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<InputComponent
|
<InputComponent
|
||||||
|
|
@ -818,8 +882,14 @@ const CourseCreateView = ({
|
||||||
value={maxParticipants}
|
value={maxParticipants}
|
||||||
onChange={(e) => setMaxParticipants(e.target.value)}
|
onChange={(e) => setMaxParticipants(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{errors.maxParticipants && (
|
{errors.maxParticipants ? (
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.maxParticipants}</p>
|
<p className="text-red-500 text-sm mt-1">{errors.maxParticipants}</p>
|
||||||
|
) : (
|
||||||
|
spotBookingActive && (
|
||||||
|
<p className="text-gray-500 text-xs mt-1">
|
||||||
|
Höchstens {roomSpotCount} – so viele Plätze hat der Raum
|
||||||
|
</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ export type KundeBooking = {
|
||||||
trainers: string[];
|
trainers: string[];
|
||||||
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
||||||
paymentStatus: string;
|
paymentStatus: string;
|
||||||
|
/** Gebuchter Platz bei Kursen mit Platzwahl, z.B. "Reformer 3". */
|
||||||
|
spotLabel: string | null;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
businessName: string;
|
businessName: string;
|
||||||
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
|
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
|
||||||
|
|
@ -84,6 +86,7 @@ export async function getKundeDetail(
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
paymentStatus: true,
|
paymentStatus: true,
|
||||||
|
spotLabel: true,
|
||||||
course: {
|
course: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
@ -178,6 +181,7 @@ export async function getKundeDetail(
|
||||||
trainers: b.course.trainers,
|
trainers: b.course.trainers,
|
||||||
trainersMap,
|
trainersMap,
|
||||||
paymentStatus: b.paymentStatus,
|
paymentStatus: b.paymentStatus,
|
||||||
|
spotLabel: b.spotLabel,
|
||||||
isPast: courseStart < now,
|
isPast: courseStart < now,
|
||||||
businessName: b.course.business?.name ?? branding.companyName,
|
businessName: b.course.business?.name ?? branding.companyName,
|
||||||
businessBranding: {
|
businessBranding: {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ function toPublicCourse(b: KundeBooking): PublicCourse {
|
||||||
currentParticipants: b.currentParticipants,
|
currentParticipants: b.currentParticipants,
|
||||||
description: b.description,
|
description: b.description,
|
||||||
roomName: b.roomName,
|
roomName: b.roomName,
|
||||||
|
spotLabel: b.spotLabel,
|
||||||
coverImage: b.coverImage ?? undefined,
|
coverImage: b.coverImage ?? undefined,
|
||||||
trainerProfiles: b.trainerProfiles,
|
trainerProfiles: b.trainerProfiles,
|
||||||
};
|
};
|
||||||
|
|
@ -132,9 +133,12 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
|
||||||
{/* Datum + Raum */}
|
{/* Datum + Raum */}
|
||||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||||
<span>{dateFormatted}</span>
|
<span>{dateFormatted}</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1 min-w-0">
|
||||||
<MapPin size={13} className="shrink-0" />
|
<MapPin size={13} className="shrink-0" />
|
||||||
{booking.roomName}
|
<span className="truncate">
|
||||||
|
{booking.roomName}
|
||||||
|
{booking.spotLabel && ` · ${booking.spotLabel}`}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,20 @@ import { prisma } from "@/src/lib/prisma";
|
||||||
import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
|
import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
|
||||||
import { roomSchema, type RoomFormData } from "../schemas/room-schema";
|
import { roomSchema, type RoomFormData } from "../schemas/room-schema";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { Prisma } from "@/src/generated/prisma/client";
|
||||||
|
import { bookableSpots, parseFloorPlan, spotDisplayName, type FloorPlan } from "../lib/floor-plan";
|
||||||
|
import { berlinDayStart, dayKey } from "@/src/modules/courses/lib/course-days";
|
||||||
|
import { refreshPublicCourses } from "@/src/modules/courses/lib/public-courses-cache";
|
||||||
|
|
||||||
|
/** Ein Grundriss ohne Geräte wird als "kein Grundriss" gespeichert. */
|
||||||
|
function floorPlanColumn(plan: FloorPlan | null | undefined) {
|
||||||
|
return plan && plan.items.length > 0 ? (plan as Prisma.InputJsonValue) : Prisma.JsonNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinNames(names: string[]): string {
|
||||||
|
if (names.length <= 1) return names.join("");
|
||||||
|
return `${names.slice(0, -1).join(", ")} und ${names[names.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt einen neuen Raum in der Datenbank
|
* Erstellt einen neuen Raum in der Datenbank
|
||||||
|
|
@ -68,6 +82,7 @@ export async function createRoom(data: RoomFormData) {
|
||||||
data: {
|
data: {
|
||||||
name: validatedData.name,
|
name: validatedData.name,
|
||||||
createdBy: creatorId,
|
createdBy: creatorId,
|
||||||
|
floorPlan: floorPlanColumn(validatedData.floorPlan),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -314,22 +329,90 @@ export async function updateRoom(roomId: string, data: RoomFormData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const validatedData = validation.data;
|
const validatedData = validation.data;
|
||||||
|
const newPlan =
|
||||||
|
validatedData.floorPlan && validatedData.floorPlan.items.length > 0
|
||||||
|
? validatedData.floorPlan
|
||||||
|
: null;
|
||||||
|
const todayStart = berlinDayStart(dayKey(new Date()));
|
||||||
|
|
||||||
// Schritt 8: Raum in der Datenbank aktualisieren
|
// Schritt 9: Gebuchte Plätze schützen. Kommende Buchungen zeigen auf die
|
||||||
|
// ID eines Geräts und tragen seinen Namen ("Reformer 3") - ein solches
|
||||||
|
// Gerät darf verschoben, aber nicht entfernt oder umbenannt werden, sonst
|
||||||
|
// stünde der Kunde vor einem Platz, den es so nicht mehr gibt.
|
||||||
|
const newById = new Map((newPlan?.items ?? []).map((item) => [item.id, item]));
|
||||||
|
const touchedSpots = bookableSpots(parseFloorPlan(existingRoom.floorPlan)).filter((spot) => {
|
||||||
|
const updated = newById.get(spot.id);
|
||||||
|
return !updated || spotDisplayName(updated) !== spotDisplayName(spot);
|
||||||
|
});
|
||||||
|
if (touchedSpots.length > 0) {
|
||||||
|
const booked = await prisma.courseBooking.findMany({
|
||||||
|
where: {
|
||||||
|
spotId: { in: touchedSpots.map((s) => s.id) },
|
||||||
|
paymentStatus: "paid",
|
||||||
|
course: { room: roomId, date: { gte: todayStart } },
|
||||||
|
},
|
||||||
|
select: { spotId: true },
|
||||||
|
distinct: ["spotId"],
|
||||||
|
});
|
||||||
|
if (booked.length > 0) {
|
||||||
|
const bookedIds = new Set(booked.map((b) => b.spotId));
|
||||||
|
const names = touchedSpots.filter((s) => bookedIds.has(s.id)).map(spotDisplayName);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`${joinNames(names)} ${names.length === 1 ? "ist" : "sind"} noch für kommende Kurse gebucht. ` +
|
||||||
|
`Verschieben geht jederzeit, entfernen oder umnummerieren erst nach diesen Kursen.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 10: Raum in der Datenbank aktualisieren
|
||||||
await prisma.room.update({
|
await prisma.room.update({
|
||||||
where: { id: roomId },
|
where: { id: roomId },
|
||||||
data: {
|
data: {
|
||||||
name: validatedData.name,
|
name: validatedData.name,
|
||||||
|
floorPlan: floorPlanColumn(newPlan),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schritt 9: Next.js Cache für /rooms Seite invalidieren
|
// Schritt 11: Kurse mit Platzwahl in diesem Raum dürfen nicht mehr
|
||||||
|
// Teilnehmer erwarten, als es Plätze gibt - sonst würde die Zahlung des
|
||||||
|
// letzten Kunden im Webhook mangels Platz erstattet. Ohne Geräte entfällt
|
||||||
|
// die Platzwahl ganz, dann bleibt die Teilnehmerzahl wie sie ist.
|
||||||
|
const spotCount = bookableSpots(newPlan).length;
|
||||||
|
let cappedCount = 0;
|
||||||
|
if (spotCount > 0) {
|
||||||
|
const toCap = await prisma.course.findMany({
|
||||||
|
where: {
|
||||||
|
room: roomId,
|
||||||
|
createdBy: managerId,
|
||||||
|
spotBooking: true,
|
||||||
|
maxParticipants: { gt: spotCount },
|
||||||
|
OR: [{ date: { gte: todayStart } }, { isStandingOrder: true }],
|
||||||
|
},
|
||||||
|
select: { id: true, businessId: true, isStandingOrder: true },
|
||||||
|
});
|
||||||
|
if (toCap.length > 0) {
|
||||||
|
await prisma.course.updateMany({
|
||||||
|
where: { id: { in: toCap.map((c) => c.id) } },
|
||||||
|
data: { maxParticipants: spotCount },
|
||||||
|
});
|
||||||
|
cappedCount = toCap.filter((c) => !c.isStandingOrder).length;
|
||||||
|
revalidatePath("/courses");
|
||||||
|
refreshPublicCourses(...toCap.map((c) => c.businessId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 12: Next.js Cache für /rooms Seite invalidieren
|
||||||
revalidatePath("/rooms");
|
revalidatePath("/rooms");
|
||||||
revalidatePath(`/rooms/edit/${roomId}`);
|
revalidatePath(`/rooms/edit/${roomId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Raum erfolgreich aktualisiert",
|
message:
|
||||||
|
cappedCount > 0
|
||||||
|
? `Raum gespeichert. ${cappedCount} kommende ${cappedCount === 1 ? "Kurs hat" : "Kurse haben"} jetzt höchstens ${spotCount} Plätze.`
|
||||||
|
: "Raum erfolgreich aktualisiert",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating room:", error);
|
console.error("Error updating room:", error);
|
||||||
|
|
|
||||||
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 { z } from "zod";
|
||||||
|
import { floorPlanSchema } from "../lib/floor-plan";
|
||||||
|
|
||||||
export const roomSchema = z.object({
|
export const roomSchema = z.object({
|
||||||
name: z.string().min(1, "Name ist erforderlich"),
|
name: z.string().min(1, "Name ist erforderlich"),
|
||||||
|
// null = Raum ohne Grundriss (z.B. Matten-Raum ohne feste Plätze)
|
||||||
|
floorPlan: floorPlanSchema.nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RoomFormData = z.infer<typeof roomSchema>;
|
export type RoomFormData = z.infer<typeof roomSchema>;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ export type Room = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
/** Rohwert aus der DB - über parseFloorPlan() lesen. */
|
||||||
|
floorPlan?: unknown;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Pen, Trash2 } from "lucide-react";
|
||||||
*/
|
*/
|
||||||
interface RoomsListItemProps {
|
interface RoomsListItemProps {
|
||||||
roomName: string; // Name des Raumes
|
roomName: string; // Name des Raumes
|
||||||
|
spotCount: number; // Buchbare Plätze laut Grundriss, 0 = kein Grundriss
|
||||||
onEdit?: () => void; // Callback-Funktion beim Klick auf Bearbeiten-Button
|
onEdit?: () => void; // Callback-Funktion beim Klick auf Bearbeiten-Button
|
||||||
onDelete?: () => void; // Callback-Funktion beim Klick auf Löschen-Button
|
onDelete?: () => void; // Callback-Funktion beim Klick auf Löschen-Button
|
||||||
showDeleteIcon?: boolean; // Ob das Löschen-Icon angezeigt werden soll
|
showDeleteIcon?: boolean; // Ob das Löschen-Icon angezeigt werden soll
|
||||||
|
|
@ -12,6 +13,7 @@ interface RoomsListItemProps {
|
||||||
|
|
||||||
const RoomsListItem: React.FC<RoomsListItemProps> = ({
|
const RoomsListItem: React.FC<RoomsListItemProps> = ({
|
||||||
roomName,
|
roomName,
|
||||||
|
spotCount,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
showDeleteIcon,
|
showDeleteIcon,
|
||||||
|
|
@ -21,6 +23,11 @@ const RoomsListItem: React.FC<RoomsListItemProps> = ({
|
||||||
<div className="w-full flex items-center justify-between">
|
<div className="w-full flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-xl text-gray-900">{roomName}</h2>
|
<h2 className="text-xl text-gray-900">{roomName}</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
{spotCount === 0
|
||||||
|
? "Ohne Grundriss"
|
||||||
|
: `${spotCount} ${spotCount === 1 ? "Platz" : "Plätze"} im Grundriss`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{showDeleteIcon && (
|
{showDeleteIcon && (
|
||||||
|
|
|
||||||
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 { createRoom, updateRoom } from "../../actions/room-actions";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Room } from "../../types/room.types";
|
import { Room } from "../../types/room.types";
|
||||||
import { Trash2 } from "lucide-react";
|
import { LayoutGrid, Trash2 } from "lucide-react";
|
||||||
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
||||||
import { useDeleteRoom } from "../../hooks/useDeleteRoom";
|
import { useDeleteRoom } from "../../hooks/useDeleteRoom";
|
||||||
|
import FloorPlanEditor from "../components/floor-plan/FloorPlanEditor";
|
||||||
|
import {
|
||||||
|
DEFAULT_ROOM_SIZE,
|
||||||
|
floorPlanSchema,
|
||||||
|
parseFloorPlan,
|
||||||
|
type FloorPlan,
|
||||||
|
} from "../../lib/floor-plan";
|
||||||
|
|
||||||
interface RoomsCreateViewProps {
|
interface RoomsCreateViewProps {
|
||||||
mode?: "create" | "edit";
|
mode?: "create" | "edit";
|
||||||
|
|
@ -19,6 +26,9 @@ interface RoomsCreateViewProps {
|
||||||
const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateViewProps = {}) => {
|
const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateViewProps = {}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [roomName, setRoomName] = useState(mode === "edit" ? initialData?.name || "" : "");
|
const [roomName, setRoomName] = useState(mode === "edit" ? initialData?.name || "" : "");
|
||||||
|
const [floorPlan, setFloorPlan] = useState<FloorPlan>(
|
||||||
|
() => parseFloorPlan(initialData?.floorPlan) ?? { ...DEFAULT_ROOM_SIZE, items: [] }
|
||||||
|
);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
|
@ -39,13 +49,19 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
|
||||||
|
|
||||||
if (!roomName.trim()) newErrors.name = "Raumname ist erforderlich";
|
if (!roomName.trim()) newErrors.name = "Raumname ist erforderlich";
|
||||||
|
|
||||||
|
if (floorPlan.items.length > 0) {
|
||||||
|
const plan = floorPlanSchema.safeParse(floorPlan);
|
||||||
|
if (!plan.success)
|
||||||
|
newErrors.floorPlan = plan.error.issues[0]?.message ?? "Ungültiger Grundriss";
|
||||||
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
toast.error("Bitte füllen Sie alle erforderlichen Felder aus");
|
toast.error("Bitte prüfen Sie Ihre Eingaben");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,6 +70,7 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
|
||||||
try {
|
try {
|
||||||
const roomData = {
|
const roomData = {
|
||||||
name: roomName,
|
name: roomName,
|
||||||
|
floorPlan: floorPlan.items.length > 0 ? floorPlan : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === "edit" && !roomId) {
|
if (mode === "edit" && !roomId) {
|
||||||
|
|
@ -95,7 +112,7 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl mt-2">
|
<p className="text-xl mt-2">
|
||||||
{mode === "edit"
|
{mode === "edit"
|
||||||
? "Ändern Sie den Raumnamen und speichern Sie Ihre Änderungen"
|
? "Ändern Sie Name und Grundriss und speichern Sie Ihre Änderungen"
|
||||||
: "Alle Räume können nach dem Speichern angepasst oder gelöscht werden"}
|
: "Alle Räume können nach dem Speichern angepasst oder gelöscht werden"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,8 +122,8 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex flex-col gap-4 max-w-xl">
|
<div className="mt-6 flex flex-col gap-5 max-w-5xl">
|
||||||
<div>
|
<div className="max-w-xl">
|
||||||
<InputComponent
|
<InputComponent
|
||||||
isLabel
|
isLabel
|
||||||
label="Name"
|
label="Name"
|
||||||
|
|
@ -117,7 +134,25 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
|
||||||
/>
|
/>
|
||||||
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
|
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
|
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 sm:p-6">
|
||||||
|
<div className="flex items-start gap-2 mb-5">
|
||||||
|
<div className="flex items-center justify-center size-8 shrink-0 rounded-lg bg-[#c9a84c]/10 text-[#c9a84c]">
|
||||||
|
<LayoutGrid className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-gray-900">Grundriss (optional)</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
Stellen Sie Reformer, Matten und andere Geräte so auf, wie sie im Raum stehen. Bei
|
||||||
|
Kursen mit Platzwahl suchen sich Kunden ihr Gerät dann selbst aus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FloorPlanEditor value={floorPlan} onChange={setFloorPlan} />
|
||||||
|
{errors.floorPlan && <p className="text-red-500 text-sm mt-2">{errors.floorPlan}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pb-4">
|
||||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? "Wird gespeichert..."
|
? "Wird gespeichert..."
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,8 @@ import { useRouter } from "next/navigation";
|
||||||
import RoomsListItem from "../components/RoomsListItem";
|
import RoomsListItem from "../components/RoomsListItem";
|
||||||
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
||||||
import { useDeleteRoom } from "../../hooks/useDeleteRoom";
|
import { useDeleteRoom } from "../../hooks/useDeleteRoom";
|
||||||
|
import { bookableSpots, parseFloorPlan } from "../../lib/floor-plan";
|
||||||
type Room = {
|
import type { Room } from "../../types/room.types";
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
createdBy: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RoomsListViewProps {
|
interface RoomsListViewProps {
|
||||||
rooms: Room[];
|
rooms: Room[];
|
||||||
|
|
@ -51,6 +45,7 @@ const RoomsListView = ({ rooms, deleteMode, canEdit, canDelete }: RoomsListViewP
|
||||||
<RoomsListItem
|
<RoomsListItem
|
||||||
key={room.id}
|
key={room.id}
|
||||||
roomName={room.name}
|
roomName={room.name}
|
||||||
|
spotCount={bookableSpots(parseFloorPlan(room.floorPlan)).length}
|
||||||
onEdit={canEdit ? () => handleEditClick(room.id) : undefined}
|
onEdit={canEdit ? () => handleEditClick(room.id) : undefined}
|
||||||
onDelete={canDelete ? () => handleDeleteClick(room.id, room.name) : undefined}
|
onDelete={canDelete ? () => handleDeleteClick(room.id, room.name) : undefined}
|
||||||
showDeleteIcon={deleteMode && canDelete}
|
showDeleteIcon={deleteMode && canDelete}
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,7 @@ import { useState } from "react";
|
||||||
import { Button } from "@/src/components/ui/button";
|
import { Button } from "@/src/components/ui/button";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import RoomsListView from "./rooms-list-view";
|
import RoomsListView from "./rooms-list-view";
|
||||||
|
import type { Room } from "../../types/room.types";
|
||||||
type Room = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
createdBy: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RoomsViewProps {
|
interface RoomsViewProps {
|
||||||
rooms: Room[];
|
rooms: Room[];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { POST } from "@/src/app/api/stripe/checkout/course/route";
|
import { POST } from "@/src/app/api/stripe/checkout/course/route";
|
||||||
|
|
||||||
vi.mock("@/src/lib/auth/getUser", () => ({
|
vi.mock("@/src/lib/auth/getUser", () => ({
|
||||||
|
|
@ -18,15 +18,46 @@ vi.mock("@/src/lib/prisma", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/src/lib/stripe", () => ({
|
vi.mock("@/src/lib/stripe", () => ({
|
||||||
stripe: { checkout: { sessions: { create: vi.fn() } } },
|
stripe: { checkout: { sessions: { create: vi.fn(), retrieve: vi.fn(), expire: vi.fn() } } },
|
||||||
getPlatformFeeCents: vi.fn().mockReturnValue(50),
|
getPlatformFeeCents: vi.fn().mockReturnValue(50),
|
||||||
eurosToCents: vi.fn().mockReturnValue(2900),
|
eurosToCents: vi.fn().mockReturnValue(2900),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Rabattcodes nur für den Test, dass ein Fehler bei der Platzwahl die
|
||||||
|
// Reservierung wieder freigibt.
|
||||||
|
vi.mock("@/src/modules/settings/lib/discount-validation", () => ({
|
||||||
|
reserveDiscountCode: vi.fn(),
|
||||||
|
releaseDiscountReservation: vi.fn().mockResolvedValue(undefined),
|
||||||
|
calculateDiscountedPriceCents: () => ({ discountedPriceCents: 2000, discountAmountCents: 900 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Kurse ohne Platzwahl liefern null - so bleiben alle übrigen Tests unberührt.
|
||||||
|
vi.mock("@/src/modules/courses/lib/course-spots", () => ({
|
||||||
|
getCourseSpots: vi.fn().mockResolvedValue(null),
|
||||||
|
getOpenCheckoutHolds: vi.fn().mockResolvedValue([]),
|
||||||
|
attachSessionToHold: vi.fn().mockResolvedValue(undefined),
|
||||||
|
holdSpot: vi.fn(),
|
||||||
|
releaseSpotHold: vi.fn().mockResolvedValue(undefined),
|
||||||
|
spotHolderKey: (p: { userId?: string | null; guestEmail?: string | null }) =>
|
||||||
|
p.userId ? `user:${p.userId}` : `guest:${p.guestEmail}`,
|
||||||
|
SPOT_HOLD_MINUTES: 30,
|
||||||
|
}));
|
||||||
|
|
||||||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||||
import { prisma } from "@/src/lib/prisma";
|
import { prisma } from "@/src/lib/prisma";
|
||||||
import { stripe, getPlatformFeeCents } from "@/src/lib/stripe";
|
import { stripe, getPlatformFeeCents } from "@/src/lib/stripe";
|
||||||
|
import {
|
||||||
|
attachSessionToHold,
|
||||||
|
getCourseSpots,
|
||||||
|
getOpenCheckoutHolds,
|
||||||
|
holdSpot,
|
||||||
|
releaseSpotHold,
|
||||||
|
} from "@/src/modules/courses/lib/course-spots";
|
||||||
|
import {
|
||||||
|
reserveDiscountCode,
|
||||||
|
releaseDiscountReservation,
|
||||||
|
} from "@/src/modules/settings/lib/discount-validation";
|
||||||
|
|
||||||
let ipCounter = 0;
|
let ipCounter = 0;
|
||||||
|
|
||||||
|
|
@ -230,3 +261,187 @@ describe("POST /api/stripe/checkout/course – Checkout-Seite", () => {
|
||||||
expect(retry.billing_address_collection).toBe("required");
|
expect(retry.billing_address_collection).toBe("required");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("POST /api/stripe/checkout/course – Platzwahl", () => {
|
||||||
|
const reformer = (id: string, label: string) => ({
|
||||||
|
id,
|
||||||
|
type: "reformer" as const,
|
||||||
|
x: 1,
|
||||||
|
y: 1.5,
|
||||||
|
rotation: 0 as const,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
|
||||||
|
vi.mocked(isUser).mockReturnValue(true);
|
||||||
|
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||||||
|
vi.mocked(getCourseSpots).mockResolvedValue([reformer("spot-1", "1"), reformer("spot-2", "2")]);
|
||||||
|
vi.mocked(holdSpot).mockReset();
|
||||||
|
vi.mocked(releaseSpotHold).mockClear();
|
||||||
|
vi.mocked(attachSessionToHold).mockClear();
|
||||||
|
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
|
||||||
|
vi.mocked(stripe.checkout.sessions.retrieve).mockReset();
|
||||||
|
vi.mocked(stripe.checkout.sessions.expire).mockReset();
|
||||||
|
vi.mocked(releaseDiscountReservation).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.mocked(getCourseSpots).mockResolvedValue(null);
|
||||||
|
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verlangt einen Platz, bevor es zu Stripe geht", async () => {
|
||||||
|
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect((await res.json()).spotUnavailable).toBe(true);
|
||||||
|
expect(holdSpot).not.toHaveBeenCalled();
|
||||||
|
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lehnt einen Platz ab, den gerade jemand anderes bucht", async () => {
|
||||||
|
vi.mocked(holdSpot).mockResolvedValue({ held: false, reason: "Platz gerade vergeben" });
|
||||||
|
|
||||||
|
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(await res.json()).toMatchObject({
|
||||||
|
error: "Platz gerade vergeben",
|
||||||
|
spotUnavailable: true,
|
||||||
|
});
|
||||||
|
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hält den Platz und lässt die Session mit dem Hold verfallen", async () => {
|
||||||
|
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||||
|
|
||||||
|
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
expect(vi.mocked(holdSpot).mock.calls[0][1]).toMatchObject({
|
||||||
|
courseId: "course-1",
|
||||||
|
spotId: "spot-2",
|
||||||
|
holderKey: "user:user-1",
|
||||||
|
});
|
||||||
|
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
|
||||||
|
expires_at?: number;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
line_items: { price_data: { product_data: { description: string } } }[];
|
||||||
|
};
|
||||||
|
expect(args.metadata).toMatchObject({ spotId: "spot-2", spotHoldId: "hold-1" });
|
||||||
|
expect(args.line_items[0].price_data.product_data.description).toContain("Reformer 2");
|
||||||
|
const inMinutes = (args.expires_at! * 1000 - Date.now()) / 60_000;
|
||||||
|
expect(inMinutes).toBeGreaterThan(29);
|
||||||
|
expect(inMinutes).toBeLessThanOrEqual(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt den Platz frei, wenn Stripe die Session nicht anlegt", async () => {
|
||||||
|
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||||
|
vi.mocked(stripe.checkout.sessions.create).mockRejectedValueOnce(new Error("Stripe down"));
|
||||||
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(releaseSpotHold).toHaveBeenCalledWith(prisma, "hold-1");
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt ohne Platzwahl kein Ablaufdatum - Stripes Standard bleibt", async () => {
|
||||||
|
vi.mocked(getCourseSpots).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
|
||||||
|
expires_at?: number;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(args.expires_at).toBeUndefined();
|
||||||
|
expect(args.metadata.spotId).toBe("");
|
||||||
|
expect(holdSpot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt den Rabattcode frei, wenn die Platzwahl mit einem Fehler abbricht", async () => {
|
||||||
|
vi.mocked(reserveDiscountCode).mockResolvedValue({
|
||||||
|
reserved: true,
|
||||||
|
reservationToken: "tok-1",
|
||||||
|
discountCode: { id: "code-1", type: "percent", value: 30 },
|
||||||
|
} as never);
|
||||||
|
vi.mocked(holdSpot).mockRejectedValue(new Error("DB weg"));
|
||||||
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
makeRequest({ courseId: "course-1", spotId: "spot-1", discountCode: "PILATES30" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(releaseDiscountReservation).toHaveBeenCalledWith(prisma, "tok-1");
|
||||||
|
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hängt die neue Session an den Hold", async () => {
|
||||||
|
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||||
|
vi.mocked(stripe.checkout.sessions.create).mockResolvedValueOnce({
|
||||||
|
id: "cs_neu",
|
||||||
|
url: "https://checkout.stripe.com/neu",
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
|
||||||
|
|
||||||
|
expect(attachSessionToHold).toHaveBeenCalledWith(prisma, "hold-1", "cs_neu");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("beendet einen alten, noch offenen Checkout derselben Person", async () => {
|
||||||
|
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||||||
|
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||||||
|
]);
|
||||||
|
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
|
||||||
|
id: "cs_alt",
|
||||||
|
status: "open",
|
||||||
|
} as never);
|
||||||
|
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||||
|
|
||||||
|
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||||
|
|
||||||
|
expect(stripe.checkout.sessions.expire).toHaveBeenCalledWith(
|
||||||
|
"cs_alt",
|
||||||
|
{},
|
||||||
|
{ stripeAccount: "acct_123" }
|
||||||
|
);
|
||||||
|
expect(releaseSpotHold).toHaveBeenCalledWith(prisma, "hold-alt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt den Platz eines schon bezahlten Checkouts stehen, bis der Webhook kommt", async () => {
|
||||||
|
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||||||
|
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||||||
|
]);
|
||||||
|
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
|
||||||
|
id: "cs_alt",
|
||||||
|
status: "complete",
|
||||||
|
} as never);
|
||||||
|
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||||
|
|
||||||
|
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||||
|
|
||||||
|
expect(stripe.checkout.sessions.expire).not.toHaveBeenCalled();
|
||||||
|
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt den alten Hold im Zweifel stehen, wenn Stripe nicht antwortet", async () => {
|
||||||
|
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||||||
|
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||||||
|
]);
|
||||||
|
vi.mocked(stripe.checkout.sessions.retrieve).mockRejectedValue(new Error("Timeout"));
|
||||||
|
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||||
|
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
|
||||||
|
consoleWarn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ vi.mock("@/src/lib/prisma", () => ({
|
||||||
course: { findUnique: vi.fn() },
|
course: { findUnique: vi.fn() },
|
||||||
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), count: vi.fn() },
|
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), count: vi.fn() },
|
||||||
courseWaitlistEntry: { deleteMany: vi.fn() },
|
courseWaitlistEntry: { deleteMany: vi.fn() },
|
||||||
|
courseSpotHold: { deleteMany: vi.fn(), updateMany: vi.fn() },
|
||||||
business: { findUnique: vi.fn(), update: vi.fn() },
|
business: { findUnique: vi.fn(), update: vi.fn() },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -53,8 +54,16 @@ import { releaseDiscountReservation } from "@/src/modules/settings/lib/discount-
|
||||||
|
|
||||||
// Die Datenbank-Transaktion der Kapazitätsprüfung.
|
// Die Datenbank-Transaktion der Kapazitätsprüfung.
|
||||||
const tx = {
|
const tx = {
|
||||||
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), count: vi.fn(), upsert: vi.fn() },
|
courseBooking: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
upsert: vi.fn(),
|
||||||
|
},
|
||||||
course: { findUnique: vi.fn() },
|
course: { findUnique: vi.fn() },
|
||||||
|
room: { findUnique: vi.fn() },
|
||||||
|
courseSpotHold: { findMany: vi.fn() },
|
||||||
};
|
};
|
||||||
|
|
||||||
function request() {
|
function request() {
|
||||||
|
|
@ -112,6 +121,9 @@ beforeEach(() => {
|
||||||
tx.courseBooking.count.mockResolvedValue(0);
|
tx.courseBooking.count.mockResolvedValue(0);
|
||||||
tx.courseBooking.upsert.mockResolvedValue({ id: "booking-1" });
|
tx.courseBooking.upsert.mockResolvedValue({ id: "booking-1" });
|
||||||
tx.course.findUnique.mockResolvedValue({ maxParticipants: 10 });
|
tx.course.findUnique.mockResolvedValue({ maxParticipants: 10 });
|
||||||
|
tx.courseBooking.findMany.mockResolvedValue([]);
|
||||||
|
tx.courseSpotHold.findMany.mockResolvedValue([]);
|
||||||
|
tx.room.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
|
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
|
||||||
application_fee_amount: 50,
|
application_fee_amount: 50,
|
||||||
|
|
@ -298,3 +310,97 @@ describe("Stripe-Webhook – nie bezahlt und Gebühren", () => {
|
||||||
expect(recordStripeFeeForCharge).not.toHaveBeenCalled();
|
expect(recordStripeFeeForCharge).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Stripe-Webhook – Platzwahl", () => {
|
||||||
|
const reformer = (id: string, label: string, x: number) => ({
|
||||||
|
id,
|
||||||
|
type: "reformer",
|
||||||
|
x,
|
||||||
|
y: 1.5,
|
||||||
|
rotation: 0,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tx.course.findUnique.mockResolvedValue({
|
||||||
|
maxParticipants: 10,
|
||||||
|
spotBooking: true,
|
||||||
|
room: "room-1",
|
||||||
|
});
|
||||||
|
tx.room.findUnique.mockResolvedValue({
|
||||||
|
floorPlan: {
|
||||||
|
width: 8,
|
||||||
|
depth: 6,
|
||||||
|
items: [reformer("spot-1", "1", 1), reformer("spot-2", "2", 2)],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const spotSession = (type = "checkout.session.completed", paymentStatus = "paid") =>
|
||||||
|
sessionEvent(type, {
|
||||||
|
account: "acct_studio",
|
||||||
|
paymentStatus,
|
||||||
|
metadata: { spotId: "spot-1", spotHoldId: "hold-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schreibt den gewählten Platz an die Buchung und gibt den Hold frei", async () => {
|
||||||
|
await deliver(spotSession(), "whsec_connect");
|
||||||
|
|
||||||
|
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||||||
|
paymentStatus: "paid",
|
||||||
|
spotId: "spot-1",
|
||||||
|
spotLabel: "Reformer 1",
|
||||||
|
});
|
||||||
|
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({ where: { id: "hold-1" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt den nächsten freien Platz, wenn der Wunschplatz inzwischen bezahlt ist", async () => {
|
||||||
|
tx.courseBooking.findMany.mockResolvedValue([{ spotId: "spot-1" }]);
|
||||||
|
|
||||||
|
await deliver(spotSession(), "whsec_connect");
|
||||||
|
|
||||||
|
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||||||
|
paymentStatus: "paid",
|
||||||
|
spotId: "spot-2",
|
||||||
|
spotLabel: "Reformer 2",
|
||||||
|
});
|
||||||
|
expect(refundStripePayment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erstattet wie bei einem vollen Kurs, wenn kein Platz mehr frei ist", async () => {
|
||||||
|
tx.courseBooking.count.mockResolvedValue(2);
|
||||||
|
tx.courseBooking.findMany.mockResolvedValue([{ spotId: "spot-1" }, { spotId: "spot-2" }]);
|
||||||
|
|
||||||
|
await deliver(spotSession(), "whsec_connect");
|
||||||
|
|
||||||
|
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||||||
|
paymentStatus: "failed",
|
||||||
|
});
|
||||||
|
expect(refundStripePayment).toHaveBeenCalledWith({
|
||||||
|
paymentIntentId: "pi_1",
|
||||||
|
stripeAccountId: "acct_studio",
|
||||||
|
refundApplicationFee: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hält den Platz bei SEPA, bis die Zahlung ankommt", async () => {
|
||||||
|
await deliver(spotSession("checkout.session.completed", "unpaid"), "whsec_connect");
|
||||||
|
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
const call = vi.mocked(prisma.courseSpotHold.updateMany).mock.calls[0][0] as {
|
||||||
|
where: { id: string };
|
||||||
|
data: { expiresAt: Date; status: string };
|
||||||
|
};
|
||||||
|
expect(call.where).toEqual({ id: "hold-1" });
|
||||||
|
// Ein neuer Checkout derselben Person darf diesen Platz nicht freigeben.
|
||||||
|
expect(call.data.status).toBe("awaiting_payment");
|
||||||
|
const days = (call.data.expiresAt.getTime() - Date.now()) / 86_400_000;
|
||||||
|
expect(days).toBeGreaterThan(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt den Platz frei, wenn die Session abläuft", async () => {
|
||||||
|
await deliver(spotSession("checkout.session.expired", "unpaid"), "whsec_connect");
|
||||||
|
|
||||||
|
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({ where: { id: "hold-1" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
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