Start time grid #62
176
frontend/src/components/StartTimeGrid/BookingForm.tsx
Normal file
176
frontend/src/components/StartTimeGrid/BookingForm.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { InlineModalDivider } from "../InlineModal/InlineModal";
|
||||||
|
import TextInput from "../TextInput/TextInput";
|
||||||
|
import ParticipantPicker from "../ParticipantPicker/ParticipantPicker";
|
||||||
|
import Dropdown from "../Dropdown/Dropdown";
|
||||||
|
import Button from "../Button/Button";
|
||||||
|
import type { EndTimeOption } from "./useGroupRoomBooking";
|
||||||
|
|
||||||
|
export interface BookingFormData {
|
||||||
|
endTime: string;
|
||||||
|
title: string;
|
||||||
|
participants: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingFormProps<T> {
|
||||||
|
endTimeOptions: EndTimeOption[];
|
||||||
|
participantOptions: T[];
|
||||||
|
getOptionValue: (option: T) => string;
|
||||||
|
getOptionLabel: (option: T) => string;
|
||||||
|
getOptionSubtitle?: (option: T) => string | undefined;
|
||||||
|
roomName?: string;
|
||||||
|
/** Minimum number of participants required for a booking */
|
||||||
|
minimumParticipants: number;
|
||||||
|
onSubmit: (data: BookingFormData) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
/** Shows loading state and disables form interactions */
|
||||||
|
loading?: boolean;
|
||||||
|
/** General API error message to display */
|
||||||
|
error?: string;
|
||||||
|
/** Field-level API errors from server validation */
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookingForm<T>({
|
||||||
|
endTimeOptions,
|
||||||
|
participantOptions,
|
||||||
|
getOptionValue,
|
||||||
|
getOptionLabel,
|
||||||
|
getOptionSubtitle,
|
||||||
|
roomName,
|
||||||
|
minimumParticipants,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
loading = false,
|
||||||
|
error,
|
||||||
|
fieldErrors = {},
|
||||||
|
}: BookingFormProps<T>) {
|
||||||
|
const [endTime, setEndTime] = useState("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [participants, setParticipants] = useState<string[]>([]);
|
||||||
|
const [localErrors, setLocalErrors] = useState({
|
||||||
|
endTime: false,
|
||||||
|
title: false,
|
||||||
|
participants: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const newErrors = {
|
||||||
|
endTime: !endTime,
|
||||||
|
title: !title.trim(),
|
||||||
|
participants: participants.length < minimumParticipants,
|
||||||
|
};
|
||||||
|
setLocalErrors(newErrors);
|
||||||
|
|
||||||
|
if (!newErrors.endTime && !newErrors.title && !newErrors.participants) {
|
||||||
|
onSubmit({ endTime, title, participants });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge local validation errors with API field errors
|
||||||
|
const hasEndTimeError = localErrors.endTime || !!fieldErrors.endTime;
|
||||||
|
const hasTitleError = localErrors.title || !!fieldErrors.title;
|
||||||
|
const hasParticipantsError =
|
||||||
|
localErrors.participants || !!fieldErrors.participants;
|
||||||
|
|
||||||
|
const getEndTimeMessage = () => {
|
||||||
|
if (fieldErrors.endTime) return fieldErrors.endTime;
|
||||||
|
if (localErrors.endTime) return "Sluttid är obligatoriskt";
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitleMessage = () => {
|
||||||
|
if (fieldErrors.title) return fieldErrors.title;
|
||||||
|
if (localErrors.title) return "Titel är obligatoriskt";
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getParticipantsMessage = () => {
|
||||||
|
if (fieldErrors.participants) return fieldErrors.participants;
|
||||||
|
if (localErrors.participants)
|
||||||
|
return `Minst ${minimumParticipants} deltagare krävs`;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<div className="p-(--spacing-md) bg-critical-background text-critical-ink-strong body-normal-sm rounded-(--border-radius-md) mb-(--spacing-md)">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Dropdown
|
||||||
|
options={endTimeOptions}
|
||||||
|
getOptionValue={(o) => o.value}
|
||||||
|
getOptionLabel={(o) => o.label}
|
||||||
|
value={endTime}
|
||||||
|
onChange={(v) => {
|
||||||
|
setEndTime(v);
|
||||||
|
if (v) setLocalErrors((prev) => ({ ...prev, endTime: false }));
|
||||||
|
}}
|
||||||
|
placeholder="Välj sluttid"
|
||||||
|
label="Sluttid"
|
||||||
|
fullWidth
|
||||||
|
error={hasEndTimeError}
|
||||||
|
message={getEndTimeMessage()}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Bokningstitel"
|
||||||
|
placeholder="Bokningstitel"
|
||||||
|
fullWidth
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTitle(e.target.value);
|
||||||
|
if (e.target.value)
|
||||||
|
setLocalErrors((prev) => ({ ...prev, title: false }));
|
||||||
|
}}
|
||||||
|
error={hasTitleError}
|
||||||
|
message={getTitleMessage()}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<ParticipantPicker
|
||||||
|
options={participantOptions}
|
||||||
|
getOptionValue={getOptionValue}
|
||||||
|
getOptionLabel={getOptionLabel}
|
||||||
|
getOptionSubtitle={getOptionSubtitle}
|
||||||
|
value={participants}
|
||||||
|
onChange={(value) => {
|
||||||
|
setParticipants(value);
|
||||||
|
if (value.length >= minimumParticipants)
|
||||||
|
setLocalErrors((prev) => ({ ...prev, participants: false }));
|
||||||
|
}}
|
||||||
|
placeholder="Sök deltagare"
|
||||||
|
label="Deltagare"
|
||||||
|
fullWidth
|
||||||
|
error={hasParticipantsError}
|
||||||
|
message={getParticipantsMessage()}
|
||||||
|
/>
|
||||||
|
{roomName && (
|
||||||
|
<div>
|
||||||
|
<span className="body-normal-sm text-base-ink-muted">Rum</span>
|
||||||
|
<p className="body-normal-md text-base-ink-strong">{roomName}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<InlineModalDivider />
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Bokar..." : "Boka"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
frontend/src/components/StartTimeGrid/StartTimeGrid.tsx
Normal file
80
frontend/src/components/StartTimeGrid/StartTimeGrid.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import Choicebox from "../Choicebox/Choicebox";
|
||||||
|
import InlineModal from "../InlineModal/InlineModal";
|
||||||
|
|
||||||
|
export interface TimeSlot {
|
||||||
|
time: string;
|
||||||
|
label: string;
|
||||||
|
unavailable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartTimeGridProps {
|
||||||
|
timeSlots: TimeSlot[];
|
||||||
|
selectedTime: string | null;
|
||||||
|
onChange: (time: string) => void;
|
||||||
|
heading?: string;
|
||||||
|
status?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StartTimeGrid({
|
||||||
|
timeSlots,
|
||||||
|
selectedTime,
|
||||||
|
onChange,
|
||||||
|
heading = "Välj starttid",
|
||||||
|
status = "Visar lediga tider",
|
||||||
|
children,
|
||||||
|
}: StartTimeGridProps) {
|
||||||
|
// Track which column was selected for arrow positioning
|
||||||
|
const selectedIndex = timeSlots.findIndex((s) => s.time === selectedTime);
|
||||||
|
const selectedColumn = selectedIndex >= 0 ? selectedIndex % 2 : 0;
|
||||||
|
|
||||||
|
// Split into rows of 2
|
||||||
|
const rows: TimeSlot[][] = [];
|
||||||
|
for (let i = 0; i < timeSlots.length; i += 2) {
|
||||||
|
rows.push(timeSlots.slice(i, i + 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-(--spacing-sm)">
|
||||||
|
<span className="body-bold-md text-base-ink-strong">{heading}</span>
|
||||||
|
<span className="body-light-sm text-base-ink-placeholder">
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-(--spacing-md) py-(--spacing-lg)">
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<div key={rowIndex}>
|
||||||
|
<div className="grid grid-cols-2 gap-(--spacing-md)">
|
||||||
|
{row.map((slot) => (
|
||||||
|
<Choicebox
|
||||||
|
key={slot.time}
|
||||||
|
primaryText={slot.time}
|
||||||
|
secondaryText={slot.label}
|
||||||
|
unavailable={slot.unavailable}
|
||||||
|
name="start-time"
|
||||||
|
value={slot.time}
|
||||||
|
checked={selectedTime === slot.time}
|
||||||
|
onChange={() => onChange(slot.time)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTime &&
|
||||||
|
row.some((slot) => slot.time === selectedTime) &&
|
||||||
|
children && (
|
||||||
|
<InlineModal
|
||||||
|
arrowPosition={selectedColumn === 0 ? "left" : "right"}
|
||||||
|
className="my-(--spacing-md) max-w-none"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</InlineModal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
318
frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts
Normal file
318
frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
|
/** A booking with ISO 8601 datetime strings */
|
||||||
|
export interface Booking {
|
||||||
|
start: string; // e.g. "2025-12-20T10:00:00"
|
||||||
|
end: string; // e.g. "2025-12-20T12:00:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bookable room with its current bookings */
|
||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
capacity: number;
|
||||||
|
bookings: Booking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the booking system.
|
||||||
|
*/
|
||||||
|
export interface BookingContext {
|
||||||
|
rooms: Room[];
|
||||||
|
/** ISO 8601 duration, e.g. "PT4H" for 4 hours */
|
||||||
|
maxBookableLength: string;
|
||||||
|
/** Time of day, e.g. "08:00:00" */
|
||||||
|
earliestBookingTime: string;
|
||||||
|
/** Time of day, e.g. "20:00:00" */
|
||||||
|
latestBookingTime: string;
|
||||||
|
minimumParticipants: number;
|
||||||
|
maxDaysInFuture: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A selectable time slot in the booking grid */
|
||||||
|
export interface TimeSlot {
|
||||||
|
/** Time of day, e.g. "10:00" */
|
||||||
|
time: string;
|
||||||
|
/** Display label, e.g. "Upp till 2 h" */
|
||||||
|
label: string;
|
||||||
|
/** True if no rooms are available at this time */
|
||||||
|
unavailable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse ISO 8601 duration (e.g. "PT4H") to hours */
|
||||||
|
function parseDurationToHours(duration: string): number {
|
||||||
|
const match = duration.match(/PT(\d+)H/);
|
||||||
|
return match ? parseInt(match[1], 10) : 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeToMinutes(time: string): number {
|
||||||
|
const [hours, minutes] = time.split(":").map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesToTime(minutes: number): string {
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a date string is today */
|
||||||
|
function isToday(date: string): boolean {
|
||||||
|
return date === new Date().toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current time in minutes since midnight */
|
||||||
|
function getCurrentTimeMinutes(): number {
|
||||||
|
const now = new Date();
|
||||||
|
return now.getHours() * 60 + now.getMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate time slots for the booking grid.
|
||||||
|
* Each slot shows the maximum bookable duration across all (or filtered) rooms.
|
||||||
|
* Past time slots are marked as unavailable.
|
||||||
|
*/
|
||||||
|
function calculateTimeSlots(
|
||||||
|
context: BookingContext,
|
||||||
|
date: string,
|
||||||
|
roomId?: string,
|
||||||
|
): TimeSlot[] {
|
||||||
|
const maxHours = parseDurationToHours(context.maxBookableLength);
|
||||||
|
const earliestMinutes = timeToMinutes(context.earliestBookingTime);
|
||||||
|
const latestMinutes = timeToMinutes(context.latestBookingTime);
|
||||||
|
const roomsToCheck = roomId
|
||||||
|
? context.rooms.filter((r) => r.id === roomId)
|
||||||
|
: context.rooms;
|
||||||
|
|
||||||
|
const checkingToday = isToday(date);
|
||||||
|
const currentMinutes = checkingToday ? getCurrentTimeMinutes() : 0;
|
||||||
|
|
||||||
|
const slots: TimeSlot[] = [];
|
||||||
|
|
||||||
|
for (let mins = earliestMinutes; mins < latestMinutes; mins += 30) {
|
||||||
|
const slotTime = minutesToTime(mins);
|
||||||
|
|
||||||
|
// Mark past slots as unavailable
|
||||||
|
if (checkingToday && mins <= currentMinutes) {
|
||||||
|
slots.push({ time: slotTime, label: "", unavailable: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxAvailableMinutes = 0;
|
||||||
|
|
||||||
|
for (const room of roomsToCheck) {
|
||||||
|
let availableMinutes = Math.min(latestMinutes - mins, maxHours * 60);
|
||||||
|
|
||||||
|
for (const booking of room.bookings) {
|
||||||
|
if (!booking.start.startsWith(date)) continue;
|
||||||
|
const bookingStart = timeToMinutes(booking.start.split("T")[1]);
|
||||||
|
const bookingEnd = timeToMinutes(booking.end.split("T")[1]);
|
||||||
|
|
||||||
|
if (mins >= bookingStart && mins < bookingEnd) {
|
||||||
|
availableMinutes = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (bookingStart > mins && bookingStart < mins + availableMinutes) {
|
||||||
|
availableMinutes = bookingStart - mins;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxAvailableMinutes = Math.max(maxAvailableMinutes, availableMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxAvailableMinutes === 0) {
|
||||||
|
slots.push({ time: slotTime, label: "", unavailable: true });
|
||||||
|
} else {
|
||||||
|
const hours = maxAvailableMinutes / 60;
|
||||||
|
const label =
|
||||||
|
hours >= 1
|
||||||
|
? `Upp till ${hours} h`
|
||||||
|
: `Upp till ${maxAvailableMinutes} min`;
|
||||||
|
slots.push({ time: slotTime, label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the room with the longest available duration at the given start time.
|
||||||
|
* Used when no specific room is filtered.
|
||||||
|
*/
|
||||||
|
function findBestRoom(
|
||||||
|
context: BookingContext,
|
||||||
|
date: string,
|
||||||
|
startTime: string,
|
||||||
|
): Room | null {
|
||||||
|
const maxHours = parseDurationToHours(context.maxBookableLength);
|
||||||
|
const latestMinutes = timeToMinutes(context.latestBookingTime);
|
||||||
|
const startMinutes = timeToMinutes(startTime);
|
||||||
|
|
||||||
|
let bestRoom: Room | null = null;
|
||||||
|
let bestDuration = 0;
|
||||||
|
|
||||||
|
for (const room of context.rooms) {
|
||||||
|
let availableMinutes = Math.min(
|
||||||
|
latestMinutes - startMinutes,
|
||||||
|
maxHours * 60,
|
||||||
|
);
|
||||||
|
let roomAvailable = true;
|
||||||
|
|
||||||
|
for (const booking of room.bookings) {
|
||||||
|
if (!booking.start.startsWith(date)) continue;
|
||||||
|
const bookingStart = timeToMinutes(booking.start.split("T")[1]);
|
||||||
|
const bookingEnd = timeToMinutes(booking.end.split("T")[1]);
|
||||||
|
|
||||||
|
if (startMinutes >= bookingStart && startMinutes < bookingEnd) {
|
||||||
|
roomAvailable = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bookingStart > startMinutes &&
|
||||||
|
bookingStart < startMinutes + availableMinutes
|
||||||
|
) {
|
||||||
|
availableMinutes = bookingStart - startMinutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomAvailable && availableMinutes > bestDuration) {
|
||||||
|
bestDuration = availableMinutes;
|
||||||
|
bestRoom = room;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestRoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An end time option with value and display label */
|
||||||
|
export interface EndTimeOption {
|
||||||
|
/** Time value, e.g. "12:30" */
|
||||||
|
value: string;
|
||||||
|
/** Display label, e.g. "12:30 · 1,5 h" */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format duration in minutes to Swedish display format */
|
||||||
|
function formatDuration(minutes: number): string {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes} min`;
|
||||||
|
}
|
||||||
|
const hours = minutes / 60;
|
||||||
|
if (Number.isInteger(hours)) {
|
||||||
|
return `${hours} h`;
|
||||||
|
}
|
||||||
|
// Use comma as decimal separator for Swedish
|
||||||
|
return `${hours.toString().replace(".", ",")} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate valid end time options for the booking form.
|
||||||
|
* Returns 30-minute increments up to the next booking or max duration.
|
||||||
|
*/
|
||||||
|
function calculateEndTimeOptions(
|
||||||
|
context: BookingContext,
|
||||||
|
date: string,
|
||||||
|
startTime: string,
|
||||||
|
roomId: string,
|
||||||
|
): EndTimeOption[] {
|
||||||
|
const maxHours = parseDurationToHours(context.maxBookableLength);
|
||||||
|
const latestMinutes = timeToMinutes(context.latestBookingTime);
|
||||||
|
const startMinutes = timeToMinutes(startTime);
|
||||||
|
const room = context.rooms.find((r) => r.id === roomId);
|
||||||
|
if (!room) return [];
|
||||||
|
|
||||||
|
let maxEndMinutes = Math.min(startMinutes + maxHours * 60, latestMinutes);
|
||||||
|
|
||||||
|
for (const booking of room.bookings) {
|
||||||
|
if (!booking.start.startsWith(date)) continue;
|
||||||
|
const bookingStart = timeToMinutes(booking.start.split("T")[1]);
|
||||||
|
const bookingEnd = timeToMinutes(booking.end.split("T")[1]);
|
||||||
|
|
||||||
|
// If start time is during a booking, no options available
|
||||||
|
if (startMinutes >= bookingStart && startMinutes < bookingEnd) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bookingStart > startMinutes && bookingStart < maxEndMinutes) {
|
||||||
|
maxEndMinutes = bookingStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: EndTimeOption[] = [];
|
||||||
|
for (let mins = startMinutes + 30; mins <= maxEndMinutes; mins += 30) {
|
||||||
|
const time = minutesToTime(mins);
|
||||||
|
const duration = formatDuration(mins - startMinutes);
|
||||||
|
options.push({
|
||||||
|
value: time,
|
||||||
|
label: `${time} · ${duration}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing group room booking state and derived data.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Room filtering
|
||||||
|
* - Time slot availability calculation
|
||||||
|
* - Automatic room selection (picks room with longest available duration)
|
||||||
|
* - End time options based on selected start time and room
|
||||||
|
*
|
||||||
|
* @param context - Booking configuration from backend
|
||||||
|
* @param date - The date to show bookings for (ISO format, e.g. "2025-12-20")
|
||||||
|
*
|
||||||
|
* @returns
|
||||||
|
* - `roomFilter` / `setRoomFilter` - Filter to show only specific room's availability
|
||||||
|
* - `roomOptions` - Options for the room filter dropdown
|
||||||
|
* - `timeSlots` - Available time slots for the StartTimeGrid
|
||||||
|
* - `selectedTime` / `setSelectedTime` - Currently selected start time
|
||||||
|
* - `bookedRoom` - The room that will be booked (filtered room or best available)
|
||||||
|
* - `endTimeOptions` - Valid end times for the booking form dropdown
|
||||||
|
* - `clearSelection` - Reset the selected time
|
||||||
|
*/
|
||||||
|
export function useGroupRoomBooking(context: BookingContext, date: string) {
|
||||||
|
const [roomFilter, setRoomFilter] = useState<string>("");
|
||||||
|
const [selectedTime, setSelectedTime] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const roomOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "", label: "Alla rum" },
|
||||||
|
...context.rooms.map((room) => ({ value: room.id, label: room.name })),
|
||||||
|
],
|
||||||
|
[context.rooms],
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeSlots = useMemo(
|
||||||
|
() => calculateTimeSlots(context, date, roomFilter || undefined),
|
||||||
|
[context, date, roomFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookedRoom = useMemo(() => {
|
||||||
|
if (roomFilter) {
|
||||||
|
return context.rooms.find((r) => r.id === roomFilter) || null;
|
||||||
|
}
|
||||||
|
if (selectedTime) {
|
||||||
|
return findBestRoom(context, date, selectedTime);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [context, date, roomFilter, selectedTime]);
|
||||||
|
|
||||||
|
const endTimeOptions = useMemo(() => {
|
||||||
|
if (!selectedTime || !bookedRoom) return [];
|
||||||
|
return calculateEndTimeOptions(context, date, selectedTime, bookedRoom.id);
|
||||||
|
}, [context, date, selectedTime, bookedRoom]);
|
||||||
|
|
||||||
|
const clearSelection = () => setSelectedTime(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roomFilter,
|
||||||
|
setRoomFilter,
|
||||||
|
roomOptions,
|
||||||
|
timeSlots,
|
||||||
|
selectedTime,
|
||||||
|
setSelectedTime,
|
||||||
|
bookedRoom,
|
||||||
|
endTimeOptions,
|
||||||
|
clearSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
frontend/src/lib/errors.ts
Normal file
23
frontend/src/lib/errors.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* API error response following RFC 7807 Problem Details format.
|
||||||
|
*/
|
||||||
|
export interface ApiError {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
status: number;
|
||||||
|
violations?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if an error object is an ApiError.
|
||||||
|
*/
|
||||||
|
export function isApiError(error: unknown): error is ApiError {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"type" in error &&
|
||||||
|
"title" in error &&
|
||||||
|
typeof (error as ApiError).type === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
import Dropdown from "../../components/Dropdown/Dropdown";
|
||||||
|
import StartTimeGrid from "../../components/StartTimeGrid/StartTimeGrid";
|
||||||
|
import BookingForm from "../../components/StartTimeGrid/BookingForm";
|
||||||
|
import { useGroupRoomBooking } from "../../components/StartTimeGrid/useGroupRoomBooking";
|
||||||
|
import {
|
||||||
|
peopleOptions,
|
||||||
|
getPersonValue,
|
||||||
|
getPersonLabel,
|
||||||
|
getPersonSubtitle,
|
||||||
|
} from "./data";
|
||||||
|
|
||||||
|
const sampleContext = {
|
||||||
|
rooms: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "G5:1",
|
||||||
|
capacity: 5,
|
||||||
|
bookings: [
|
||||||
|
{ start: "2025-12-20T10:00:00", end: "2025-12-20T12:00:00" },
|
||||||
|
{ start: "2025-12-20T14:00:00", end: "2025-12-20T16:00:00" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "G5:2",
|
||||||
|
capacity: 5,
|
||||||
|
bookings: [{ start: "2025-12-20T09:00:00", end: "2025-12-20T11:00:00" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
name: "G5:3",
|
||||||
|
capacity: 5,
|
||||||
|
bookings: [
|
||||||
|
{ start: "2025-12-20T08:00:00", end: "2025-12-20T10:00:00" },
|
||||||
|
{ start: "2025-12-20T13:00:00", end: "2025-12-20T15:00:00" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maxBookableLength: "PT4H",
|
||||||
|
earliestBookingTime: "08:00:00",
|
||||||
|
latestBookingTime: "20:00:00",
|
||||||
|
minimumParticipants: 2,
|
||||||
|
maxDaysInFuture: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StartTimeGridSection() {
|
||||||
|
const booking = useGroupRoomBooking(sampleContext, "2025-12-20");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Default</h2>
|
||||||
|
|
||||||
|
<div className="mb-md">
|
||||||
|
<Dropdown
|
||||||
|
options={booking.roomOptions}
|
||||||
|
getOptionValue={(o) => o.value}
|
||||||
|
getOptionLabel={(o) => o.label}
|
||||||
|
value={booking.roomFilter}
|
||||||
|
onChange={booking.setRoomFilter}
|
||||||
|
label="Filtrera på rum"
|
||||||
|
hideLabel
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StartTimeGrid
|
||||||
|
timeSlots={booking.timeSlots}
|
||||||
|
selectedTime={booking.selectedTime}
|
||||||
|
onChange={booking.setSelectedTime}
|
||||||
|
>
|
||||||
|
<BookingForm
|
||||||
|
key={booking.selectedTime}
|
||||||
|
endTimeOptions={booking.endTimeOptions}
|
||||||
|
participantOptions={peopleOptions}
|
||||||
|
getOptionValue={getPersonValue}
|
||||||
|
getOptionLabel={getPersonLabel}
|
||||||
|
getOptionSubtitle={getPersonSubtitle}
|
||||||
|
roomName={booking.bookedRoom?.name}
|
||||||
|
minimumParticipants={sampleContext.minimumParticipants}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
console.log("Booked:", {
|
||||||
|
roomId: booking.bookedRoom?.id,
|
||||||
|
startTime: booking.selectedTime,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
booking.clearSelection();
|
||||||
|
}}
|
||||||
|
onCancel={booking.clearSelection}
|
||||||
|
/>
|
||||||
|
</StartTimeGrid>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user