Add Booking System
This commit is contained in:
52
apps/hub/app/_components/BookingButton.tsx
Normal file
52
apps/hub/app/_components/BookingButton.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
59
apps/hub/app/_components/BookingSystem.tsx
Normal file
59
apps/hub/app/_components/BookingSystem.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
425
apps/hub/app/_components/BookingTimelineModal.tsx
Normal file
425
apps/hub/app/_components/BookingTimelineModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"}
|
||||||
|
|||||||
271
apps/hub/app/_components/NewBookingModal.tsx
Normal file
271
apps/hub/app/_components/NewBookingModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
151
apps/hub/app/_components/timeline.css
Normal file
151
apps/hub/app/_components/timeline.css
Normal 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);
|
||||||
|
}
|
||||||
141
apps/hub/app/api/booking/[id]/route.ts
Normal file
141
apps/hub/app/api/booking/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
179
apps/hub/app/api/booking/route.ts
Normal file
179
apps/hub/app/api/booking/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
32
apps/hub/app/api/station/route.ts
Normal file
32
apps/hub/app/api/station/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
|||||||
26
packages/database/prisma/schema/booking.prisma
Normal file
26
packages/database/prisma/schema/booking.prisma
Normal 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")
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -41,4 +41,5 @@ model Station {
|
|||||||
MissionsOnStations MissionsOnStations[]
|
MissionsOnStations MissionsOnStations[]
|
||||||
MissionOnStationUsers MissionOnStationUsers[]
|
MissionOnStationUsers MissionOnStationUsers[]
|
||||||
ConnectedAircraft ConnectedAircraft[]
|
ConnectedAircraft ConnectedAircraft[]
|
||||||
|
Bookings Booking[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user