Files
var-monorepo/apps/hub/app/_components/BookingTimelineModal.tsx
2025-09-18 21:49:03 +02:00

426 lines
12 KiB
TypeScript

"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>
);
};