Buchungssystem erste überarbeitungen

This commit is contained in:
PxlLoewe
2025-09-20 00:28:53 +02:00
parent a612cf9951
commit ba027957ce
9 changed files with 136 additions and 228 deletions

View File

@@ -1,5 +1,5 @@
// Helper function for distortion curve generation // Helper function for distortion curve generation
function createDistortionCurve(amount: number): Float32Array { function createDistortionCurve(amount: number): Float32Array<ArrayBuffer> {
const k = typeof amount === "number" ? amount : 50; const k = typeof amount === "number" ? amount : 50;
const nSamples = 44100; const nSamples = 44100;
const curve = new Float32Array(nSamples); const curve = new Float32Array(nSamples);

View File

@@ -0,0 +1,35 @@
import { Booking, Prisma, PublicUser, Station } from "@repo/db";
import axios from "axios";
export const getBookingsAPI = async (filter: Prisma.BookingWhereInput) => {
const res = await axios.get<
(Booking & {
Station: Station;
User: PublicUser;
})[]
>("/api/bookings", {
params: {
filter: JSON.stringify(filter),
},
});
if (res.status !== 200) {
throw new Error("Failed to fetch stations");
}
return res.data;
};
export const createBookingAPI = async (booking: Prisma.BookingCreateInput) => {
const response = await axios.post("/api/bookings", booking);
if (response.status !== 201) {
throw new Error("Failed to create booking");
}
return response.data;
};
export const deleteBookingAPI = async (bookingId: string) => {
const response = await axios.delete(`/api/bookings/${bookingId}`);
if (!response.status.toString().startsWith("2")) {
throw new Error("Failed to delete booking");
}
return bookingId;
};

View File

@@ -3,29 +3,17 @@
import { useState } from "react"; import { useState } from "react";
import { CalendarIcon } from "lucide-react"; import { CalendarIcon } from "lucide-react";
import { BookingSystem } from "./BookingSystem"; import { BookingSystem } from "./BookingSystem";
import { User } from "@repo/db";
interface BookingButtonProps { interface BookingButtonProps {
currentUser: { currentUser: User;
id: string;
emailVerified: boolean;
is_banned: boolean;
permissions: string[];
};
} }
export const BookingButton = ({ currentUser }: BookingButtonProps) => { export const BookingButton = ({ currentUser }: BookingButtonProps) => {
const [isBookingSystemOpen, setIsBookingSystemOpen] = useState(false); const [isBookingSystemOpen, setIsBookingSystemOpen] = useState(false);
// Check if user can access booking system // Check if user can access booking system
const canAccessBookingSystem = currentUser && currentUser.emailVerified && !currentUser.is_banned; const canAccessBookingSystem = currentUser && currentUser.emailVerified && !currentUser.isBanned;
const handleOpenBookingSystem = () => {
setIsBookingSystemOpen(true);
};
const handleCloseBookingSystem = () => {
setIsBookingSystemOpen(false);
};
// Don't render the button if user doesn't have access // Don't render the button if user doesn't have access
if (!canAccessBookingSystem) { if (!canAccessBookingSystem) {
@@ -37,14 +25,14 @@ export const BookingButton = ({ currentUser }: BookingButtonProps) => {
<button <button
className="btn btn-sm btn-ghost tooltip tooltip-bottom" className="btn btn-sm btn-ghost tooltip tooltip-bottom"
data-tip="Slot Buchung" data-tip="Slot Buchung"
onClick={handleOpenBookingSystem} onClick={() => setIsBookingSystemOpen(true)}
> >
<CalendarIcon size={20} /> <CalendarIcon size={20} />
</button> </button>
<BookingSystem <BookingSystem
isOpen={isBookingSystemOpen} isOpen={isBookingSystemOpen}
onClose={handleCloseBookingSystem} onClose={() => setIsBookingSystemOpen(false)}
currentUser={currentUser} currentUser={currentUser}
/> />
</> </>

View File

@@ -3,16 +3,12 @@
import { useState } from "react"; import { useState } from "react";
import { BookingTimelineModal } from "./BookingTimelineModal"; import { BookingTimelineModal } from "./BookingTimelineModal";
import { NewBookingModal } from "./NewBookingModal"; import { NewBookingModal } from "./NewBookingModal";
import { User } from "@repo/db";
interface BookingSystemProps { interface BookingSystemProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
currentUser: { currentUser: User;
id: string;
emailVerified: boolean;
is_banned: boolean;
permissions: string[];
};
} }
export const BookingSystem = ({ isOpen, onClose, currentUser }: BookingSystemProps) => { export const BookingSystem = ({ isOpen, onClose, currentUser }: BookingSystemProps) => {

View File

@@ -1,44 +1,17 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState } from "react";
import { CalendarIcon, Plus, X, ChevronLeft, ChevronRight, Trash2 } from "lucide-react"; import { CalendarIcon, Plus, X, ChevronLeft, ChevronRight, Trash2 } from "lucide-react";
import toast from "react-hot-toast"; import { Booking, getPublicUser, PublicUser, Station, User } from "@repo/db";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
interface BookingUser { import { deleteBookingAPI, getBookingsAPI } from "(app)/_querys/bookings";
id: string; import { Button } from "@repo/shared-components";
firstname: string;
lastname: string;
}
interface BookingStation {
id: number;
bosCallsign: string;
bosCallsignShort: string;
}
interface Booking {
id: string;
userId: string;
type: "STATION" | "LST_01" | "LST_02" | "LST_03" | "LST_04";
stationId?: number;
startTime: string;
endTime: string;
title?: string;
description?: string;
user: BookingUser;
station?: BookingStation;
}
interface BookingTimelineModalProps { interface BookingTimelineModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onOpenNewBooking: () => void; onOpenNewBooking: () => void;
currentUser: { currentUser: User;
id: string;
emailVerified: boolean;
is_banned: boolean;
permissions: string[];
};
} }
type ViewMode = "day" | "week" | "month"; type ViewMode = "day" | "week" | "month";
@@ -49,71 +22,9 @@ export const BookingTimelineModal = ({
onOpenNewBooking, onOpenNewBooking,
currentUser, currentUser,
}: BookingTimelineModalProps) => { }: BookingTimelineModalProps) => {
const [bookings, setBookings] = useState<Booking[]>([]); const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [viewMode, setViewMode] = useState<ViewMode>("day"); const [viewMode, setViewMode] = useState<ViewMode>("day");
// Check if user can create bookings
const canCreateBookings =
currentUser &&
currentUser.emailVerified &&
!currentUser.is_banned &&
(currentUser.permissions.includes("PILOT") || currentUser.permissions.includes("DISPO"));
// Check if user can delete a booking
const canDeleteBooking = (bookingUserId: string) => {
if (!currentUser) return false;
// User can delete their own bookings if they meet basic requirements
if (bookingUserId === currentUser.id && currentUser.emailVerified && !currentUser.is_banned) {
return true;
}
// Admins can delete any booking
return currentUser.permissions.includes("ADMIN_KICK");
};
const deleteBooking = async (bookingId: string) => {
try {
const response = await fetch(`/api/booking/${bookingId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete booking");
}
// Refresh the bookings
fetchBookings();
} catch (error) {
console.error("Error deleting booking:", error);
}
};
const fetchBookings = useCallback(async () => {
setLoading(true);
try {
const response = await fetch("/api/booking");
if (!response.ok) throw new Error("Failed to fetch bookings");
const data = await response.json();
// API returns { bookings: [] }, so we need to access the bookings property
setBookings(data.bookings || []);
} catch (error) {
console.error("Error fetching bookings:", error);
toast.error("Fehler beim Laden der Buchungen");
setBookings([]); // Set empty array on error
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
fetchBookings();
}
}, [isOpen, fetchBookings]);
const getTimeRange = () => { const getTimeRange = () => {
const start = new Date(currentDate); const start = new Date(currentDate);
const end = new Date(currentDate); const end = new Date(currentDate);
@@ -144,6 +55,51 @@ export const BookingTimelineModal = ({
return { start, end }; return { start, end };
}; };
const { data: bookings, isLoading: isBookingsLoading } = useQuery({
queryKey: ["bookings", getTimeRange().start, getTimeRange().end],
queryFn: () =>
getBookingsAPI({
startTime: {
gte: getTimeRange().start,
},
endTime: {
lte: getTimeRange().end,
},
}),
});
const { mutate: deleteBooking } = useMutation({
mutationKey: ["deleteBooking"],
mutationFn: async (bookingId: string) => {
await deleteBookingAPI(bookingId);
queryClient.invalidateQueries({ queryKey: ["bookings"] });
},
});
// Check if user can create bookings
const canCreateBookings =
currentUser &&
currentUser.emailVerified &&
!currentUser.isBanned &&
(currentUser.permissions.includes("PILOT") || currentUser.permissions.includes("DISPO"));
// Check if user can delete a booking
const canDeleteBooking = (bookingUserPublicId: string) => {
if (!currentUser) return false;
// User can delete their own bookings if they meet basic requirements
if (
bookingUserPublicId === currentUser.publicId &&
currentUser.emailVerified &&
!currentUser.isBanned
) {
return true;
}
// Admins can delete any booking
return currentUser.permissions.includes("ADMIN_KICK");
};
const navigate = (direction: "prev" | "next") => { const navigate = (direction: "prev" | "next") => {
const newDate = new Date(currentDate); const newDate = new Date(currentDate);
@@ -162,21 +118,6 @@ export const BookingTimelineModal = ({
setCurrentDate(newDate); setCurrentDate(newDate);
}; };
const getFilteredBookings = () => {
// Ensure bookings is an array before filtering
if (!Array.isArray(bookings)) {
console.warn("Bookings is not an array:", bookings);
return [];
}
const { start, end } = getTimeRange();
return bookings.filter((booking) => {
const bookingStart = new Date(booking.startTime);
const bookingEnd = new Date(booking.endTime);
return bookingStart <= end && bookingEnd >= start;
});
};
const formatDateRange = () => { const formatDateRange = () => {
const { start, end } = getTimeRange(); const { start, end } = getTimeRange();
const options: Intl.DateTimeFormatOptions = { const options: Intl.DateTimeFormatOptions = {
@@ -196,16 +137,15 @@ export const BookingTimelineModal = ({
}; };
const groupBookingsByResource = () => { const groupBookingsByResource = () => {
const filteredBookings = getFilteredBookings(); if (!bookings) return {};
// For day view, group by resource (station/type) // For day view, group by resource (station/type)
if (viewMode === "day") { if (viewMode === "day") {
const groups: Record<string, Booking[]> = {}; const groups: Record<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
filteredBookings.forEach((booking) => { bookings.forEach((booking) => {
let key: string = booking.type; let key: string = booking.type;
if (booking.station) { if (booking.Station) {
key = `${booking.station.bosCallsign}`; key = `${booking.Station.bosCallsign}`;
} }
if (!groups[key]) { if (!groups[key]) {
@@ -227,7 +167,7 @@ export const BookingTimelineModal = ({
}); });
// Sort the groups themselves - LST_ types first, then alphabetical // Sort the groups themselves - LST_ types first, then alphabetical
const sortedGroups: Record<string, Booking[]> = {}; const sortedGroups: Record<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
Object.keys(groups) Object.keys(groups)
.sort((a, b) => { .sort((a, b) => {
// Check if groups contain LST_ types // Check if groups contain LST_ types
@@ -246,9 +186,9 @@ export const BookingTimelineModal = ({
} }
// For week and month views, group by date // For week and month views, group by date
const groups: Record<string, Booking[]> = {}; const groups: Record<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
filteredBookings.forEach((booking) => { bookings.forEach((booking) => {
const dateKey = new Date(booking.startTime).toLocaleDateString("de-DE", { const dateKey = new Date(booking.startTime).toLocaleDateString("de-DE", {
weekday: "long", weekday: "long",
day: "2-digit", day: "2-digit",
@@ -263,7 +203,7 @@ export const BookingTimelineModal = ({
}); });
// Sort groups by date for week/month view and sort bookings within each group // Sort groups by date for week/month view and sort bookings within each group
const sortedGroups: Record<string, Booking[]> = {}; const sortedGroups: Record<string, (Booking & { Station: Station; User: PublicUser })[]> = {};
Object.keys(groups) Object.keys(groups)
.sort((a, b) => { .sort((a, b) => {
// Extract date from the formatted string and compare // Extract date from the formatted string and compare
@@ -290,23 +230,12 @@ export const BookingTimelineModal = ({
return sortedGroups; return sortedGroups;
}; };
const formatUserName = (user: BookingUser) => {
return `${user.firstname} ${user.lastname.charAt(0)}.`;
};
const formatTimeRange = (booking: Booking) => { const formatTimeRange = (booking: Booking) => {
const start = new Date(booking.startTime); const start = new Date(booking.startTime);
const end = new Date(booking.endTime); const end = new Date(booking.endTime);
return `${start.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })}`; return `${start.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })}`;
}; };
const getStationDisplay = (booking: Booking) => {
if (booking.station) {
return booking.station.bosCallsignShort || booking.station.bosCallsign;
}
return booking.type;
};
if (!isOpen) return null; if (!isOpen) return null;
const groupedBookings = groupBookingsByResource(); const groupedBookings = groupBookingsByResource();
@@ -349,7 +278,7 @@ export const BookingTimelineModal = ({
</div> </div>
</div> </div>
{loading ? ( {isBookingsLoading ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<span className="loading loading-spinner loading-lg"></span> <span className="loading loading-spinner loading-lg"></span>
</div> </div>
@@ -369,29 +298,29 @@ export const BookingTimelineModal = ({
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="badge badge-outline text-xs"> <span className="badge badge-outline text-xs">
{getStationDisplay(booking)} {booking.type.startsWith("LST_")
</span> ? ""
<span className="text-sm font-medium"> : booking.Station.bosCallsignShort || booking.Station.bosCallsign}
{formatUserName(booking.user)}
</span> </span>
<span className="text-sm font-medium">{booking.User.fullName}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-right"> <div className="text-right">
<p className="text-xs font-medium">{formatTimeRange(booking)}</p> <p className="text-xs font-medium">{formatTimeRange(booking)}</p>
</div> </div>
{canDeleteBooking(booking.user.id) && ( {canDeleteBooking(booking.User.publicId) && (
<button <Button
onClick={() => deleteBooking(booking.id)} onClick={() => deleteBooking(booking.id)}
className={`btn btn-xs ${ className={`btn btn-xs ${
currentUser?.permissions.includes("ADMIN_KICK") && currentUser?.permissions.includes("ADMIN_EVENT") &&
booking.user.id !== currentUser.id booking.User.publicId !== currentUser.publicId
? "btn-error" ? "btn-error"
: "btn-neutral" : "btn-neutral"
}`} }`}
title="Buchung löschen" title="Buchung löschen"
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </Button>
)} )}
</div> </div>
</div> </div>
@@ -400,7 +329,7 @@ export const BookingTimelineModal = ({
</div> </div>
</div> </div>
))} ))}
{Object.keys(groupedBookings).length === 0 && !loading && ( {Object.keys(groupedBookings).length === 0 && !isBookingsLoading && (
<div className="col-span-full py-8 text-center opacity-70"> <div className="col-span-full py-8 text-center opacity-70">
Keine Buchungen im aktuellen Zeitraum gefunden Keine Buchungen im aktuellen Zeitraum gefunden
</div> </div>

View File

@@ -136,14 +136,7 @@ export const HorizontalNav = async () => {
<div className="ml-auto flex items-center"> <div className="ml-auto flex items-center">
<ul className="flex items-center space-x-2 px-1"> <ul className="flex items-center space-x-2 px-1">
<li> <li>
<BookingButton <BookingButton currentUser={session?.user} />
currentUser={{
id: session.user.id,
emailVerified: session.user.emailVerified,
is_banned: session.user.isBanned,
permissions: session.user.permissions,
}}
/>
</li> </li>
<li> <li>
<a <a

View File

@@ -5,6 +5,7 @@ import { getServerSession } from "../../auth/[...nextauth]/auth";
// DELETE /api/booking/[id] - Delete a booking // DELETE /api/booking/[id] - Delete a booking
export const DELETE = async (req: NextRequest, { params }: { params: { id: string } }) => { export const DELETE = async (req: NextRequest, { params }: { params: { id: string } }) => {
try { try {
console.log(params);
const session = await getServerSession(); const session = await getServerSession();
if (!session?.user) { if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
@@ -117,14 +118,8 @@ export const PUT = async (req: NextRequest, { params }: { params: { id: string }
endTime: new Date(endTime), endTime: new Date(endTime),
}, },
include: { include: {
user: { User: true,
select: { Station: {
id: true,
firstname: true,
lastname: true,
},
},
station: {
select: { select: {
id: true, id: true,
bosCallsignShort: true, bosCallsignShort: true,

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@repo/db"; import { getPublicUser, prisma } from "@repo/db";
import { getServerSession } from "../auth/[...nextauth]/auth"; import { getServerSession } from "../auth/[...nextauth]/auth";
// GET /api/booking - Get all bookings for the timeline // GET /api/booking - Get all bookings for the timeline
@@ -10,47 +10,14 @@ export const GET = async (req: NextRequest) => {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
} }
const { searchParams } = new URL(req.url); const { searchParams } = req.nextUrl;
const startDate = searchParams.get("startDate"); const filter = JSON.parse(searchParams.get("filter") || "{}");
const endDate = searchParams.get("endDate");
const whereClause: Record<string, unknown> = {};
// Filter by date range if provided
if (startDate && endDate) {
whereClause.OR = [
{
startTime: {
gte: new Date(startDate),
lte: new Date(endDate),
},
},
{
endTime: {
gte: new Date(startDate),
lte: new Date(endDate),
},
},
{
AND: [
{ startTime: { lte: new Date(startDate) } },
{ endTime: { gte: new Date(endDate) } },
],
},
];
}
const bookings = await prisma.booking.findMany({ const bookings = await prisma.booking.findMany({
where: whereClause, where: filter,
include: { include: {
user: { User: true,
select: { Station: {
id: true,
firstname: true,
lastname: true,
},
},
station: {
select: { select: {
id: true, id: true,
bosCallsign: true, bosCallsign: true,
@@ -63,7 +30,12 @@ export const GET = async (req: NextRequest) => {
}, },
}); });
return NextResponse.json({ bookings }); return NextResponse.json(
bookings.map((b) => ({
...b,
user: b.User && getPublicUser(b.User),
})),
);
} catch (error) { } catch (error) {
console.error("Error fetching bookings:", error); console.error("Error fetching bookings:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 }); return NextResponse.json({ error: "Internal server error" }, { status: 500 });
@@ -154,14 +126,14 @@ export const POST = async (req: NextRequest) => {
endTime: new Date(endTime), endTime: new Date(endTime),
}, },
include: { include: {
user: { User: {
select: { select: {
id: true, id: true,
firstname: true, firstname: true,
lastname: true, lastname: true,
}, },
}, },
station: { Station: {
select: { select: {
id: true, id: true,
bosCallsign: true, bosCallsign: true,
@@ -171,7 +143,7 @@ export const POST = async (req: NextRequest) => {
}, },
}); });
return NextResponse.json({ booking }, { status: 201 }); return NextResponse.json(booking, { status: 201 });
} catch (error) { } catch (error) {
console.error("Error creating booking:", error); console.error("Error creating booking:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 }); return NextResponse.json({ error: "Internal server error" }, { status: 500 });

View File

@@ -17,8 +17,8 @@ model Booking {
updatedAt DateTime @updatedAt @map(name: "updated_at") updatedAt DateTime @updatedAt @map(name: "updated_at")
// Relations // Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id], onDelete: Cascade)
station Station? @relation(fields: [stationId], references: [id], onDelete: Cascade) Station Station? @relation(fields: [stationId], references: [id], onDelete: Cascade)
@@index([startTime, endTime]) @@index([startTime, endTime])
@@index([type, startTime, endTime]) @@index([type, startTime, endTime])