Add Booking System

This commit is contained in:
nocnico
2025-09-18 21:49:03 +02:00
parent 715cb9ef53
commit a612cf9951
14 changed files with 1380 additions and 0 deletions

View File

@@ -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 (
<>
<button
className="btn btn-sm btn-ghost tooltip tooltip-bottom"
data-tip="Slot Buchung"
onClick={handleOpenBookingSystem}
>
<CalendarIcon size={20} />
</button>
<BookingSystem
isOpen={isBookingSystemOpen}
onClose={handleCloseBookingSystem}
currentUser={currentUser}
/>
</>
);
};

View File

@@ -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 (
<>
<BookingTimelineModal
key={refreshTimeline}
isOpen={isOpen && !showNewBookingModal}
onClose={handleCloseMain}
onOpenNewBooking={handleOpenNewBooking}
currentUser={currentUser}
/>
<NewBookingModal
isOpen={showNewBookingModal}
onClose={handleCloseNewBooking}
onBookingCreated={handleBookingCreated}
userPermissions={currentUser.permissions}
/>
</>
);
};

View File

@@ -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<Booking[]>([]);
const [loading, setLoading] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date());
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 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<string, Booking[]> = {};
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<string, Booking[]> = {};
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<string, Booking[]> = {};
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<string, Booking[]> = {};
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 (
<div className="modal modal-open">
<div className="modal-box flex max-h-[83vh] w-11/12 max-w-7xl flex-col">
<div className="mb-4 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-lg font-bold">
<CalendarIcon size={24} />
Slot Buchung
</h3>
<button className="btn btn-sm btn-circle btn-ghost" onClick={onClose}>
<X size={20} />
</button>
</div>
{/* Controls */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2">
<button className="btn btn-sm btn-ghost" onClick={() => navigate("prev")}>
<ChevronLeft size={16} />
</button>
<span className="min-w-[200px] text-center font-medium">{formatDateRange()}</span>
<button className="btn btn-sm btn-ghost" onClick={() => navigate("next")}>
<ChevronRight size={16} />
</button>
</div>
<div className="join">
{(["day", "week", "month"] as ViewMode[]).map((mode) => (
<button
key={mode}
className={`btn btn-sm join-item ${viewMode === mode ? "btn-active" : ""}`}
onClick={() => setViewMode(mode)}
>
{mode === "day" ? "Tag" : mode === "week" ? "Woche" : "Monat"}
</button>
))}
</div>
</div>
{loading ? (
<div className="flex justify-center py-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
) : (
<div className="grid max-h-[calc(83vh-200px)] grid-cols-1 gap-3 overflow-y-auto md:grid-cols-2">
{Object.entries(groupedBookings).map(([groupName, resourceBookings]) => (
<div key={groupName} className="card bg-base-200 shadow-sm">
<div className="card-body p-3">
<h4 className="mb-2 text-sm font-medium opacity-70">
{viewMode === "day" ? groupName : groupName}
</h4>
<div className="space-y-1">
{resourceBookings.map((booking) => (
<div
key={booking.id}
className={`alert alert-horizontal ${booking.type.startsWith("LST_") ? "alert-success" : "alert-info"} alert-soft px-3 py-2`}
>
<div className="flex items-center gap-3">
<span className="badge badge-outline text-xs">
{getStationDisplay(booking)}
</span>
<span className="text-sm font-medium">
{formatUserName(booking.user)}
</span>
</div>
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-xs font-medium">{formatTimeRange(booking)}</p>
</div>
{canDeleteBooking(booking.user.id) && (
<button
onClick={() => deleteBooking(booking.id)}
className={`btn btn-xs ${
currentUser?.permissions.includes("ADMIN_KICK") &&
booking.user.id !== currentUser.id
? "btn-error"
: "btn-neutral"
}`}
title="Buchung löschen"
>
<Trash2 size={12} />
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
))}
{Object.keys(groupedBookings).length === 0 && !loading && (
<div className="col-span-full py-8 text-center opacity-70">
Keine Buchungen im aktuellen Zeitraum gefunden
</div>
)}
</div>
)}
<div className="modal-action">
{canCreateBookings && (
<button className="btn btn-primary" onClick={onOpenNewBooking}>
<Plus size={20} />
Neue Buchung
</button>
)}
<button className="btn" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
);
};

View File

@@ -13,6 +13,7 @@ import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "./Error"; import { Error } from "./Error";
import Image from "next/image"; import Image from "next/image";
import { Plane, Radar, Workflow } from "lucide-react"; import { Plane, Radar, Workflow } from "lucide-react";
import { BookingButton } from "./BookingButton";
export const VerticalNav = async () => { export const VerticalNav = async () => {
const session = await getServerSession(); const session = await getServerSession();
@@ -134,6 +135,16 @@ export const HorizontalNav = async () => {
</div> </div>
<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>
<BookingButton
currentUser={{
id: session.user.id,
emailVerified: session.user.emailVerified,
is_banned: session.user.isBanned,
permissions: session.user.permissions,
}}
/>
</li>
<li> <li>
<a <a
href={process.env.NEXT_PUBLIC_DISPATCH_URL + "/tracker"} href={process.env.NEXT_PUBLIC_DISPATCH_URL + "/tracker"}

View File

@@ -0,0 +1,271 @@
"use client";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { X, CalendarIcon, Clock } from "lucide-react";
import toast from "react-hot-toast";
interface Station {
id: number;
bosCallsign: string;
bosCallsignShort: string;
locationState: string;
operator: string;
aircraft: string;
}
interface NewBookingFormData {
type: "STATION" | "LST_01" | "LST_02" | "LST_03" | "LST_04";
stationId?: number;
startTime: string;
endTime: string;
title?: string;
description?: string;
}
interface NewBookingModalProps {
isOpen: boolean;
onClose: () => void;
onBookingCreated: () => void;
userPermissions: string[];
}
export const NewBookingModal = ({
isOpen,
onClose,
onBookingCreated,
userPermissions,
}: NewBookingModalProps) => {
const [stations, setStations] = useState<Station[]>([]);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors },
} = useForm<NewBookingFormData>();
const selectedType = watch("type");
const hasDISPOPermission = userPermissions.includes("DISPO");
// Fetch stations for selection
useEffect(() => {
const fetchStations = async () => {
setLoading(true);
try {
const response = await fetch("/api/station");
if (!response.ok) {
throw new Error("Failed to fetch stations");
}
const data = await response.json();
setStations(data.stations || []);
} catch (error) {
console.error("Error fetching stations:", error);
toast.error("Fehler beim Laden der Stationen");
} finally {
setLoading(false);
}
};
if (isOpen) {
fetchStations();
}
}, [isOpen]);
// Reset form when modal opens
useEffect(() => {
if (isOpen) {
reset();
// Set default datetime to current hour
const now = new Date();
const currentHour = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours(),
0,
0,
);
const nextHour = new Date(currentHour.getTime() + 60 * 60 * 1000);
setValue("startTime", currentHour.toISOString().slice(0, 16));
setValue("endTime", nextHour.toISOString().slice(0, 16));
}
}, [isOpen, reset, setValue]);
const onSubmit = async (data: NewBookingFormData) => {
setSubmitting(true);
try {
// Validate that end time is after start time
if (new Date(data.endTime) <= new Date(data.startTime)) {
toast.error("Endzeit muss nach der Startzeit liegen");
return;
}
const response = await fetch("/api/booking", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
if (response.status === 409) {
toast.error(result.error || "Konflikt: Zeitraum bereits gebucht");
} else {
toast.error(result.error || "Fehler beim Erstellen der Buchung");
}
return;
}
toast.success("Buchung erfolgreich erstellt!");
onBookingCreated();
onClose();
} catch (error) {
console.error("Error creating booking:", error);
toast.error("Fehler beim Erstellen der Buchung");
} finally {
setSubmitting(false);
}
};
if (!isOpen) return null;
return (
<div className="modal modal-open">
<div className="modal-box max-w-2xl">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-lg font-bold">
<CalendarIcon size={24} />
Neue Buchung erstellen
</h3>
<button className="btn btn-sm btn-circle btn-ghost" onClick={onClose}>
<X size={20} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Resource Type Selection */}
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Station *</span>
</label>
<select
{...register("type", { required: "Bitte wählen Sie einen Typ aus" })}
className="select select-bordered w-full"
>
<option value="">Typ auswählen...</option>
<option value="STATION">Station</option>
{hasDISPOPermission && (
<>
<option value="LST_01">LST-01</option>
<option value="LST_02">LST-02</option>
<option value="LST_03">LST-03</option>
<option value="LST_04">LST-04</option>
</>
)}
</select>
{errors.type && (
<label className="label">
<span className="label-text-alt text-error">{errors.type.message}</span>
</label>
)}
</div>
{/* Station Selection (only if STATION type is selected) */}
{selectedType === "STATION" && (
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Station *</span>
</label>
{loading ? (
<div className="skeleton h-12 w-full"></div>
) : (
<select
{...register("stationId", {
required:
selectedType === "STATION" ? "Bitte wählen Sie eine Station aus" : false,
})}
className="select select-bordered w-full"
>
<option value="">Station auswählen...</option>
{stations.map((station) => (
<option key={station.id} value={station.id}>
{station.bosCallsignShort} - {station.locationState} ({station.aircraft})
</option>
))}
</select>
)}
{errors.stationId && (
<label className="label">
<span className="label-text-alt text-error">{errors.stationId.message}</span>
</label>
)}
</div>
)}
{/* Time Selection */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="form-control">
<label className="label">
<span className="label-text flex items-center gap-1 font-semibold">
<Clock size={16} />
Startzeit *
</span>
</label>
<input
type="datetime-local"
{...register("startTime", { required: "Startzeit ist erforderlich" })}
className="input input-bordered w-full"
/>
{errors.startTime && (
<label className="label">
<span className="label-text-alt text-error">{errors.startTime.message}</span>
</label>
)}
</div>
<div className="form-control">
<label className="label">
<span className="label-text flex items-center gap-1 font-semibold">
<Clock size={16} />
Endzeit *
</span>
</label>
<input
type="datetime-local"
{...register("endTime", { required: "Endzeit ist erforderlich" })}
className="input input-bordered w-full"
/>
{errors.endTime && (
<label className="label">
<span className="label-text-alt text-error">{errors.endTime.message}</span>
</label>
)}
</div>
</div>
{/* Actions */}
<div className="modal-action">
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting && <span className="loading loading-spinner loading-sm"></span>}
Buchung erstellen
</button>
<button type="button" className="btn" onClick={onClose}>
Abbrechen
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,151 @@
/* Timeline Custom Styles for Dark/Light Mode */
/* Light Mode (default) */
.react-calendar-timeline {
background-color: oklch(var(--b1));
color: oklch(var(--bc));
}
.react-calendar-timeline .rct-cursor-line {
background-color: oklch(var(--p));
}
.react-calendar-timeline .rct-sidebar {
background-color: oklch(var(--b2));
border-right: 1px solid oklch(var(--b3));
}
.react-calendar-timeline .rct-sidebar-row {
border-bottom: 1px solid oklch(var(--b3));
color: oklch(var(--bc));
}
.react-calendar-timeline .rct-header {
background-color: oklch(var(--b2)) !important;
border-bottom: 1px solid oklch(var(--b3));
}
.react-calendar-timeline .rct-header-root {
background-color: oklch(var(--b2)) !important;
border-bottom: 1px solid oklch(var(--b3));
}
.react-calendar-timeline .rct-dateHeader {
color: oklch(var(--bc)) !important;
background-color: oklch(var(--b2)) !important;
border-right: 1px solid oklch(var(--b3));
}
.react-calendar-timeline .rct-dateHeader-primary {
background-color: oklch(var(--b2)) !important;
color: oklch(var(--bc)) !important;
border-bottom: 1px solid oklch(var(--b3));
}
/* Fix for any nested date header elements */
.react-calendar-timeline .rct-dateHeader * {
color: oklch(var(--bc)) !important;
background-color: transparent !important;
}
.react-calendar-timeline .rct-scroll {
background-color: oklch(var(--b1));
}
.react-calendar-timeline .rct-items {
background-color: oklch(var(--b1));
}
.react-calendar-timeline .rct-item {
background-color: oklch(var(--p));
color: oklch(var(--pc));
border: 1px solid oklch(var(--pf));
border-radius: 4px;
}
.react-calendar-timeline .rct-item.selected {
background-color: oklch(var(--s));
color: oklch(var(--sc));
border-color: oklch(var(--sf));
}
/* Timeline item type specific colors */
.timeline-item.station {
background-color: oklch(var(--p)) !important;
border-color: oklch(var(--pf)) !important;
}
.timeline-item.lst_01 {
background-color: oklch(var(--s)) !important;
border-color: oklch(var(--sf)) !important;
}
.timeline-item.lst_02 {
background-color: oklch(var(--a)) !important;
border-color: oklch(var(--af)) !important;
}
.timeline-item.lst_03 {
background-color: oklch(var(--n)) !important;
border-color: oklch(var(--nf)) !important;
}
.timeline-item.lst_04 {
background-color: oklch(var(--in)) !important;
border-color: oklch(var(--inf)) !important;
}
/* Station booking colors - dynamic colors for different stations */
.timeline-item.station {
background-color: oklch(var(--p)) !important;
border-color: oklch(var(--pf)) !important;
}
/* Additional station colors for variety when multiple stations are booked */
.timeline-item.station:nth-child(odd) {
background-color: oklch(var(--p)) !important;
border-color: oklch(var(--pf)) !important;
}
.timeline-item.station:nth-child(even) {
background-color: oklch(var(--s)) !important;
border-color: oklch(var(--sf)) !important;
}
/* Vertical lines */
.react-calendar-timeline .rct-vertical-line {
border-left: 1px solid oklch(var(--b3));
}
.react-calendar-timeline .rct-horizontal-line {
border-bottom: 1px solid oklch(var(--b3));
}
/* Today line */
.react-calendar-timeline .rct-today {
background-color: oklch(var(--er) / 0.2);
}
/* Hover effects */
.react-calendar-timeline .rct-item:hover {
filter: brightness(1.1);
}
/* Scrollbar styling for dark mode compatibility */
.react-calendar-timeline ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.react-calendar-timeline ::-webkit-scrollbar-track {
background: oklch(var(--b2));
}
.react-calendar-timeline ::-webkit-scrollbar-thumb {
background: oklch(var(--b3));
border-radius: 4px;
}
.react-calendar-timeline ::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.3);
}

View File

@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@repo/db";
import { getServerSession } from "../../auth/[...nextauth]/auth";
// DELETE /api/booking/[id] - Delete a booking
export const DELETE = async (req: NextRequest, { params }: { params: { id: string } }) => {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const bookingId = params.id;
// Find the booking
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
});
if (!booking) {
return NextResponse.json({ error: "Booking not found" }, { status: 404 });
}
// Check if user owns the booking or has admin permissions
if (booking.userId !== session.user.id && !session.user.permissions.includes("ADMIN_KICK")) {
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
// Delete the booking
await prisma.booking.delete({
where: { id: bookingId },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting booking:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};
// PUT /api/booking/[id] - Update a booking
export const PUT = async (req: NextRequest, { params }: { params: { id: string } }) => {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const bookingId = params.id;
const body = await req.json();
const { type, stationId, startTime, endTime } = body;
// Find the booking
const existingBooking = await prisma.booking.findUnique({
where: { id: bookingId },
});
if (!existingBooking) {
return NextResponse.json({ error: "Booking not found" }, { status: 404 });
}
// Check if user owns the booking or has admin permissions
if (
existingBooking.userId !== session.user.id &&
!session.user.permissions.includes("ADMIN_KICK")
) {
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 });
}
// Validate permissions for LST bookings
const lstTypes = ["LST_01", "LST_02", "LST_03", "LST_04"];
if (lstTypes.includes(type)) {
if (!session.user.permissions.includes("DISPO")) {
return NextResponse.json(
{ error: "Insufficient permissions for LST booking" },
{ status: 403 },
);
}
}
// Check for conflicts (excluding current booking)
const conflictWhere = {
id: { not: bookingId },
type,
OR: [
{
startTime: {
lt: new Date(endTime),
},
endTime: {
gt: new Date(startTime),
},
},
],
...(type === "STATION" && stationId ? { stationId } : {}),
};
const conflictingBooking = await prisma.booking.findFirst({
where: conflictWhere,
});
if (conflictingBooking) {
const resourceName = type === "STATION" ? `Station` : type;
return NextResponse.json(
{ error: `Konflikt erkannt: ${resourceName} ist bereits für diesen Zeitraum gebucht.` },
{ status: 409 },
);
}
// Update the booking
const updatedBooking = await prisma.booking.update({
where: { id: bookingId },
data: {
type,
stationId: type === "STATION" ? stationId : null,
startTime: new Date(startTime),
endTime: new Date(endTime),
},
include: {
user: {
select: {
id: true,
firstname: true,
lastname: true,
},
},
station: {
select: {
id: true,
bosCallsignShort: true,
},
},
},
});
return NextResponse.json({ booking: updatedBooking });
} catch (error) {
console.error("Error updating booking:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};

View File

@@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@repo/db";
import { getServerSession } from "../auth/[...nextauth]/auth";
// GET /api/booking - Get all bookings for the timeline
export const GET = async (req: NextRequest) => {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const startDate = searchParams.get("startDate");
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({
where: whereClause,
include: {
user: {
select: {
id: true,
firstname: true,
lastname: true,
},
},
station: {
select: {
id: true,
bosCallsign: true,
bosCallsignShort: true,
},
},
},
orderBy: {
startTime: "asc",
},
});
return NextResponse.json({ bookings });
} catch (error) {
console.error("Error fetching bookings:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};
// POST /api/booking - Create a new booking
export const POST = async (req: NextRequest) => {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const body = await req.json();
const { type, stationId, startTime, endTime } = body;
// Convert stationId to integer if provided
const parsedStationId = stationId ? parseInt(stationId, 10) : null;
// Validate required fields
if (!type || !startTime || !endTime) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Validate permissions for LST bookings
const lstTypes = ["LST_01", "LST_02", "LST_03", "LST_04"];
if (lstTypes.includes(type)) {
if (!session.user.permissions.includes("DISPO")) {
return NextResponse.json(
{ error: "Insufficient permissions for LST booking" },
{ status: 403 },
);
}
}
// Validate station requirement for STATION type
if (type === "STATION" && !parsedStationId) {
return NextResponse.json(
{ error: "Station ID required for station booking" },
{ status: 400 },
);
}
// Validate that stationId is a valid integer when provided
if (stationId && (isNaN(parsedStationId!) || parsedStationId! <= 0)) {
return NextResponse.json({ error: "Invalid station ID" }, { status: 400 });
}
// Check for conflicts
const conflictWhere: Record<string, unknown> = {
type,
OR: [
{
startTime: {
lt: new Date(endTime),
},
endTime: {
gt: new Date(startTime),
},
},
],
};
if (type === "STATION" && parsedStationId) {
conflictWhere.stationId = parsedStationId;
}
const existingBooking = await prisma.booking.findFirst({
where: conflictWhere,
});
if (existingBooking) {
const resourceName = type === "STATION" ? `Station` : type;
return NextResponse.json(
{ error: `Konflikt erkannt: ${resourceName} ist bereits für diesen Zeitraum gebucht.` },
{ status: 409 },
);
}
// Create the booking
const booking = await prisma.booking.create({
data: {
userId: session.user.id,
type,
stationId: type === "STATION" ? parsedStationId : null,
startTime: new Date(startTime),
endTime: new Date(endTime),
},
include: {
user: {
select: {
id: true,
firstname: true,
lastname: true,
},
},
station: {
select: {
id: true,
bosCallsign: true,
bosCallsignShort: true,
},
},
},
});
return NextResponse.json({ booking }, { status: 201 });
} catch (error) {
console.error("Error creating booking:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};

View File

@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { prisma } from "@repo/db";
import { getServerSession } from "../auth/[...nextauth]/auth";
// GET /api/station - Get all stations for booking selection
export const GET = async () => {
try {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const stations = await prisma.station.findMany({
select: {
id: true,
bosCallsign: true,
bosCallsignShort: true,
locationState: true,
operator: true,
aircraft: true,
},
orderBy: {
bosCallsignShort: "asc",
},
});
return NextResponse.json({ stations });
} catch (error) {
console.error("Error fetching stations:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};

View File

@@ -36,12 +36,14 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"moment": "^2.30.1",
"next": "^15.4.2", "next": "^15.4.2",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"next-remove-imports": "^1.0.12", "next-remove-imports": "^1.0.12",
"npm": "^11.4.2", "npm": "^11.4.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.1.0", "react": "^19.1.0",
"react-calendar-timeline": "0.30.0-beta.3",
"react-datepicker": "^8.4.0", "react-datepicker": "^8.4.0",
"react-day-picker": "^9.8.0", "react-day-picker": "^9.8.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -56,6 +58,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.31.0", "@eslint/js": "^9.31.0",
"@types/react-calendar-timeline": "^0.28.6",
"eslint": "^9.31.0", "eslint": "^9.31.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.37.0" "typescript-eslint": "^8.37.0"

View File

@@ -0,0 +1,26 @@
enum BOOKING_TYPE {
STATION
LST_01
LST_02
LST_03
LST_04
}
model Booking {
id String @id @default(uuid())
userId String @map(name: "user_id")
type BOOKING_TYPE
stationId Int? @map(name: "station_id")
startTime DateTime @map(name: "start_time")
endTime DateTime @map(name: "end_time")
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
station Station? @relation(fields: [stationId], references: [id], onDelete: Cascade)
@@index([startTime, endTime])
@@index([type, startTime, endTime])
@@map(name: "bookings")
}

View File

@@ -0,0 +1,28 @@
-- CreateEnum
CREATE TYPE "BOOKING_TYPE" AS ENUM ('STATION', 'LST_01', 'LST_02', 'LST_03', 'LST_04');
-- CreateTable
CREATE TABLE "bookings" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"type" "BOOKING_TYPE" NOT NULL,
"station_id" INTEGER,
"start_time" TIMESTAMP(3) NOT NULL,
"end_time" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "bookings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "bookings_start_time_end_time_idx" ON "bookings"("start_time", "end_time");
-- CreateIndex
CREATE INDEX "bookings_type_start_time_end_time_idx" ON "bookings"("type", "start_time", "end_time");
-- AddForeignKey
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_station_id_fkey" FOREIGN KEY ("station_id") REFERENCES "Station"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -41,4 +41,5 @@ model Station {
MissionsOnStations MissionsOnStations[] MissionsOnStations MissionsOnStations[]
MissionOnStationUsers MissionOnStationUsers[] MissionOnStationUsers MissionOnStationUsers[]
ConnectedAircraft ConnectedAircraft[] ConnectedAircraft ConnectedAircraft[]
Bookings Booking[]
} }

View File

@@ -76,6 +76,7 @@ model User {
PositionLog PositionLog[] PositionLog PositionLog[]
Penaltys Penalty[] Penaltys Penalty[]
CreatedPenalties Penalty[] @relation("CreatedPenalties") CreatedPenalties Penalty[] @relation("CreatedPenalties")
Bookings Booking[]
@@map(name: "users") @@map(name: "users")
} }