From a612cf9951d98b314149597901a1fd81f81983b0 Mon Sep 17 00:00:00 2001 From: nocnico Date: Thu, 18 Sep 2025 21:49:03 +0200 Subject: [PATCH] Add Booking System --- apps/hub/app/_components/BookingButton.tsx | 52 +++ apps/hub/app/_components/BookingSystem.tsx | 59 +++ .../app/_components/BookingTimelineModal.tsx | 425 ++++++++++++++++++ apps/hub/app/_components/Nav.tsx | 11 + apps/hub/app/_components/NewBookingModal.tsx | 271 +++++++++++ apps/hub/app/_components/timeline.css | 151 +++++++ apps/hub/app/api/booking/[id]/route.ts | 141 ++++++ apps/hub/app/api/booking/route.ts | 179 ++++++++ apps/hub/app/api/station/route.ts | 32 ++ apps/hub/package.json | 3 + .../database/prisma/schema/booking.prisma | 26 ++ .../20250918174836_bookings/migration.sql | 28 ++ .../database/prisma/schema/station.prisma | 1 + packages/database/prisma/schema/user.prisma | 1 + 14 files changed, 1380 insertions(+) create mode 100644 apps/hub/app/_components/BookingButton.tsx create mode 100644 apps/hub/app/_components/BookingSystem.tsx create mode 100644 apps/hub/app/_components/BookingTimelineModal.tsx create mode 100644 apps/hub/app/_components/NewBookingModal.tsx create mode 100644 apps/hub/app/_components/timeline.css create mode 100644 apps/hub/app/api/booking/[id]/route.ts create mode 100644 apps/hub/app/api/booking/route.ts create mode 100644 apps/hub/app/api/station/route.ts create mode 100644 packages/database/prisma/schema/booking.prisma create mode 100644 packages/database/prisma/schema/migrations/20250918174836_bookings/migration.sql diff --git a/apps/hub/app/_components/BookingButton.tsx b/apps/hub/app/_components/BookingButton.tsx new file mode 100644 index 00000000..bee3f695 --- /dev/null +++ b/apps/hub/app/_components/BookingButton.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState } from "react"; +import { CalendarIcon } from "lucide-react"; +import { BookingSystem } from "./BookingSystem"; + +interface BookingButtonProps { + currentUser: { + id: string; + emailVerified: boolean; + is_banned: boolean; + permissions: string[]; + }; +} + +export const BookingButton = ({ currentUser }: BookingButtonProps) => { + const [isBookingSystemOpen, setIsBookingSystemOpen] = useState(false); + + // Check if user can access booking system + const canAccessBookingSystem = currentUser && currentUser.emailVerified && !currentUser.is_banned; + + const handleOpenBookingSystem = () => { + setIsBookingSystemOpen(true); + }; + + const handleCloseBookingSystem = () => { + setIsBookingSystemOpen(false); + }; + + // Don't render the button if user doesn't have access + if (!canAccessBookingSystem) { + return null; + } + + return ( + <> + + + + + ); +}; diff --git a/apps/hub/app/_components/BookingSystem.tsx b/apps/hub/app/_components/BookingSystem.tsx new file mode 100644 index 00000000..91798e2a --- /dev/null +++ b/apps/hub/app/_components/BookingSystem.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; +import { BookingTimelineModal } from "./BookingTimelineModal"; +import { NewBookingModal } from "./NewBookingModal"; + +interface BookingSystemProps { + isOpen: boolean; + onClose: () => void; + currentUser: { + id: string; + emailVerified: boolean; + is_banned: boolean; + permissions: string[]; + }; +} + +export const BookingSystem = ({ isOpen, onClose, currentUser }: BookingSystemProps) => { + const [showNewBookingModal, setShowNewBookingModal] = useState(false); + const [refreshTimeline, setRefreshTimeline] = useState(0); + + const handleOpenNewBooking = () => { + setShowNewBookingModal(true); + }; + + const handleCloseNewBooking = () => { + setShowNewBookingModal(false); + }; + + const handleBookingCreated = () => { + // Trigger a refresh of the timeline + setRefreshTimeline((prev) => prev + 1); + setShowNewBookingModal(false); + }; + + const handleCloseMain = () => { + setShowNewBookingModal(false); + onClose(); + }; + + return ( + <> + + + + + ); +}; diff --git a/apps/hub/app/_components/BookingTimelineModal.tsx b/apps/hub/app/_components/BookingTimelineModal.tsx new file mode 100644 index 00000000..09504d90 --- /dev/null +++ b/apps/hub/app/_components/BookingTimelineModal.tsx @@ -0,0 +1,425 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { CalendarIcon, Plus, X, ChevronLeft, ChevronRight, Trash2 } from "lucide-react"; +import toast from "react-hot-toast"; + +interface BookingUser { + id: string; + 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 { + isOpen: boolean; + onClose: () => void; + onOpenNewBooking: () => void; + currentUser: { + id: string; + emailVerified: boolean; + is_banned: boolean; + permissions: string[]; + }; +} + +type ViewMode = "day" | "week" | "month"; + +export const BookingTimelineModal = ({ + isOpen, + onClose, + onOpenNewBooking, + currentUser, +}: BookingTimelineModalProps) => { + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(false); + const [currentDate, setCurrentDate] = useState(new Date()); + const [viewMode, setViewMode] = useState("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 start = new Date(currentDate); + const end = new Date(currentDate); + + switch (viewMode) { + case "day": + start.setHours(0, 0, 0, 0); + end.setHours(23, 59, 59, 999); + break; + case "week": { + const dayOfWeek = start.getDay(); + const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + start.setDate(start.getDate() + mondayOffset); + start.setHours(0, 0, 0, 0); + end.setDate(start.getDate() + 6); + end.setHours(23, 59, 59, 999); + break; + } + case "month": + start.setDate(1); + start.setHours(0, 0, 0, 0); + end.setMonth(end.getMonth() + 1); + end.setDate(0); + end.setHours(23, 59, 59, 999); + break; + } + + return { start, end }; + }; + + const navigate = (direction: "prev" | "next") => { + const newDate = new Date(currentDate); + + switch (viewMode) { + case "day": + newDate.setDate(newDate.getDate() + (direction === "next" ? 1 : -1)); + break; + case "week": + newDate.setDate(newDate.getDate() + (direction === "next" ? 7 : -7)); + break; + case "month": + newDate.setMonth(newDate.getMonth() + (direction === "next" ? 1 : -1)); + break; + } + + 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 { start, end } = getTimeRange(); + const options: Intl.DateTimeFormatOptions = { + day: "2-digit", + month: "2-digit", + year: "numeric", + }; + + switch (viewMode) { + case "day": + return start.toLocaleDateString("de-DE", options); + case "week": + return `${start.toLocaleDateString("de-DE", options)} - ${end.toLocaleDateString("de-DE", options)}`; + case "month": + return start.toLocaleDateString("de-DE", { month: "long", year: "numeric" }); + } + }; + + const groupBookingsByResource = () => { + const filteredBookings = getFilteredBookings(); + + // For day view, group by resource (station/type) + if (viewMode === "day") { + const groups: Record = {}; + + filteredBookings.forEach((booking) => { + let key: string = booking.type; + if (booking.station) { + key = `${booking.station.bosCallsign}`; + } + + if (!groups[key]) { + groups[key] = []; + } + groups[key]!.push(booking); + }); + + // Sort bookings within each group, LST_ types first, then alphanumerical + Object.keys(groups).forEach((key) => { + groups[key] = groups[key]!.sort((a, b) => { + const aIsLST = a.type.startsWith("LST_"); + const bIsLST = b.type.startsWith("LST_"); + if (aIsLST && !bIsLST) return -1; + if (!aIsLST && bIsLST) return 1; + // Within same category (both LST_ or both non-LST_), sort alphanumerically by type + return a.type.localeCompare(b.type); + }); + }); + + // Sort the groups themselves - LST_ types first, then alphabetical + const sortedGroups: Record = {}; + Object.keys(groups) + .sort((a, b) => { + // Check if groups contain LST_ types + const aHasLST = groups[a]?.some((booking) => booking.type.startsWith("LST_")); + const bHasLST = groups[b]?.some((booking) => booking.type.startsWith("LST_")); + if (aHasLST && !bHasLST) return -1; + if (!aHasLST && bHasLST) return 1; + // Within same category, sort alphabetically by group name + return a.localeCompare(b); + }) + .forEach((key) => { + sortedGroups[key] = groups[key]!; + }); + + return sortedGroups; + } + + // For week and month views, group by date + const groups: Record = {}; + + filteredBookings.forEach((booking) => { + const dateKey = new Date(booking.startTime).toLocaleDateString("de-DE", { + weekday: "long", + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + + if (!groups[dateKey]) { + groups[dateKey] = []; + } + groups[dateKey]!.push(booking); + }); + + // Sort groups by date for week/month view and sort bookings within each group + const sortedGroups: Record = {}; + Object.keys(groups) + .sort((a, b) => { + // Extract date from the formatted string and compare + const dateA = groups[a]?.[0]?.startTime; + const dateB = groups[b]?.[0]?.startTime; + if (!dateA || !dateB) return 0; + return new Date(dateA).getTime() - new Date(dateB).getTime(); + }) + .forEach((key) => { + const bookingsForKey = groups[key]; + if (bookingsForKey) { + // Sort bookings within each date group, LST_ types first, then alphanumerical + sortedGroups[key] = bookingsForKey.sort((a, b) => { + const aIsLST = a.type.startsWith("LST_"); + const bIsLST = b.type.startsWith("LST_"); + if (aIsLST && !bIsLST) return -1; + if (!aIsLST && bIsLST) return 1; + // Within same category (both LST_ or both non-LST_), sort alphanumerically by type + return a.type.localeCompare(b.type); + }); + } + }); + + return sortedGroups; + }; + + const formatUserName = (user: BookingUser) => { + return `${user.firstname} ${user.lastname.charAt(0)}.`; + }; + + const formatTimeRange = (booking: Booking) => { + const start = new Date(booking.startTime); + 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" })}`; + }; + + const getStationDisplay = (booking: Booking) => { + if (booking.station) { + return booking.station.bosCallsignShort || booking.station.bosCallsign; + } + return booking.type; + }; + + if (!isOpen) return null; + + const groupedBookings = groupBookingsByResource(); + + return ( +
+
+
+

+ + Slot Buchung +

+ +
+ + {/* Controls */} +
+
+ + {formatDateRange()} + +
+ +
+ {(["day", "week", "month"] as ViewMode[]).map((mode) => ( + + ))} +
+
+ + {loading ? ( +
+ +
+ ) : ( +
+ {Object.entries(groupedBookings).map(([groupName, resourceBookings]) => ( +
+
+

+ {viewMode === "day" ? groupName : groupName} +

+
+ {resourceBookings.map((booking) => ( +
+
+ + {getStationDisplay(booking)} + + + {formatUserName(booking.user)} + +
+
+
+

{formatTimeRange(booking)}

+
+ {canDeleteBooking(booking.user.id) && ( + + )} +
+
+ ))} +
+
+
+ ))} + {Object.keys(groupedBookings).length === 0 && !loading && ( +
+ Keine Buchungen im aktuellen Zeitraum gefunden +
+ )} +
+ )} + +
+ {canCreateBookings && ( + + )} + +
+
+
+ ); +}; diff --git a/apps/hub/app/_components/Nav.tsx b/apps/hub/app/_components/Nav.tsx index 3f34bbea..172b38e1 100644 --- a/apps/hub/app/_components/Nav.tsx +++ b/apps/hub/app/_components/Nav.tsx @@ -13,6 +13,7 @@ import { getServerSession } from "api/auth/[...nextauth]/auth"; import { Error } from "./Error"; import Image from "next/image"; import { Plane, Radar, Workflow } from "lucide-react"; +import { BookingButton } from "./BookingButton"; export const VerticalNav = async () => { const session = await getServerSession(); @@ -134,6 +135,16 @@ export const HorizontalNav = async () => {