Adding more fixes to the system.

Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
2025-09-09 12:54:35 -04:00
parent 2d10c91732
commit 0d120bb8a8
33 changed files with 653 additions and 316 deletions

View File

@@ -5,6 +5,8 @@
*
* Author: Cliff Hill
* Last updated: 2025-09-05
* @component
* @returns {JSX.Element} App root UI
*/
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";

View File

@@ -9,6 +9,12 @@
* Last updated: 2025-09-08
*/
/**
/**
* SSE payload for rooms availability stream.
*/
import type { RoomsAvailabilitySSEPayload } from "../interfaces";
/**
* Connect to the rooms availability SSE stream.
* @param url SSE endpoint URL
@@ -22,13 +28,13 @@ export function connectRoomsAvailabilityStream({
onError,
}: {
url?: string;
onMessage: (data: any) => void;
onError?: (error: any) => void;
onMessage: (data: RoomsAvailabilitySSEPayload) => void;
onError?: (error: unknown) => void;
}) {
const es = new window.EventSource(url);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const data: RoomsAvailabilitySSEPayload = JSON.parse(event.data);
onMessage(data);
} catch (e) {
if (onError) onError(e);

View File

@@ -10,34 +10,37 @@
* Last updated: 2025-09-08
*/
import axios from "axios";
import type { User } from "../interfaces";
/**
* Fetch all users (invitees).
* @returns Array of users
*/
export async function getUsers() {
const res = await axios.get("/users/");
export async function getUsers(): Promise<User[]> {
const res = await axios.get<User[]>("/users/");
return res.data;
}
/**
* Fetch available users for a given time slot.
* @param start_time ISO string
* @param end_time ISO string
* @param exclude_booking_id Optional booking ID to exclude
* @param params Object containing start_time, end_time, and optional exclude_booking_id
* @returns Array of available users
*/
export async function getAvailableUsers({
start_time,
end_time,
exclude_booking_id,
}: {
export async function getAvailableUsers(params: {
start_time: string;
end_time: string;
exclude_booking_id?: string | number;
}) {
const params: any = { start_time, end_time };
if (exclude_booking_id) params.exclude_booking_id = exclude_booking_id;
const res = await axios.get("/users/available/", { params });
}): Promise<User[]> {
const query: {
start_time: string;
end_time: string;
exclude_booking_id?: string | number;
} = {
start_time: params.start_time,
end_time: params.end_time,
};
if (params.exclude_booking_id)
query.exclude_booking_id = params.exclude_booking_id;
const res = await axios.get<User[]>("/users/available/", { params: query });
return res.data;
}

View File

@@ -5,19 +5,14 @@
*
* Author: Cliff Hill
* Last updated: 2025-09-05
*/
/**
* BookingConfirmation component.
*
* Displays booking confirmation details for a room reservation.
*
* @component
* @param {BookingConfirmationProps} props - Component props
* @returns {JSX.Element} Confirmation UI
*/
import React from "react";
import { Box, Button } from "@mui/material";
import type { BookingConfirmationProps } from "../schemas";
import type { BookingConfirmationProps } from "../interfaces";
/**
* BookingConfirmation displays a summary of a confirmed booking.

View File

@@ -1,10 +1,8 @@
/**
* BookingForm.tsx
* BookingForm component
* Form for creating or editing a conference room booking.
* Handles validation, submission, and invitee selection.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
* @returns {JSX.Element} Booking form UI
*/
import React, { useState, useEffect, useRef } from "react";
@@ -22,7 +20,7 @@ import { roundToStrictlyFutureQuarter } from "../utils/date";
import { connectRoomsAvailabilityStream } from "../apis/sse";
import { getInvitees } from "../apis/invitees";
import { getAvailableUsers } from "../apis/users";
import type { ConferenceRoom, Booking } from "../schemas";
import type { ConferenceRoom, Booking } from "../interfaces";
import {
Box,
Button,
@@ -293,7 +291,9 @@ const BookingForm: React.FC<BookingFormProps> = ({
}
getAvailableUsers(params)
.then((users) => {
const emails = users.map((u: { email: string }) => u.email);
const emails = users.map(
(u: import("../interfaces").User) => u.email ?? ""
);
setAvailableInvitees(Array.from(new Set([...emails, ...invitees])));
})
.catch(() =>
@@ -377,7 +377,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
const filtered = data.bookings.filter((b: Booking) => {
const bookingDate = b.start_time?.slice(0, 10);
return (
String((b as any).room_id) === String(room_id) &&
String(b.room_id) === String(room_id) &&
(!dateStr || bookingDate === dateStr)
);
});
@@ -612,9 +612,11 @@ const BookingForm: React.FC<BookingFormProps> = ({
<RoomDetailsModal
open={roomModalOpen}
onClose={() => setRoomModalOpen(false)}
room={rooms.find(
(r: ConferenceRoom) => String(r.id) === String(room_id)
)}
room={
rooms.find(
(r: ConferenceRoom) => String(r.id) === String(room_id)
) ?? rooms[0]
}
/>
<TextField
label="Title (optional)"

View File

@@ -1,10 +1,8 @@
/**
* BookingList.tsx
* BookingList component
* Lists bookings for a selected room or user, including times and invitees.
* Handles empty state and sorts bookings chronologically.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
* @returns {JSX.Element} Booking list UI
*/
import React from "react";
@@ -14,7 +12,7 @@ import {
formatBookingTime,
getInviteeName,
} from "../helpers/bookingList";
import type { BookingListProps } from "../schemas";
import type { BookingListProps } from "../interfaces";
/**
* BookingList component.

View File

@@ -21,42 +21,12 @@ import Typography from "@mui/material/Typography";
import { getEventDisplayText, getRoomClass } from "../helpers/calendar";
import { logger } from "../utils/logger";
import "../styles/CalendarView.css";
// Custom event type for calendar events
type BookingEvent = {
id: string | number;
title: string;
start: Date | string;
end: Date | string;
color?: string;
resource?: {
room_id?: string | number;
room_name?: string;
invitees?: string[];
originalBooking?: any;
};
};
import type { BookingEvent } from "../types";
/**
* Props for CalendarView.
*/
interface SlotInfo {
start: Date;
end: Date;
allDay: boolean;
viewType: string;
selectedRoomId?: string | number;
}
interface CalendarViewProps {
events: BookingEvent[];
onEventClick?: (event: BookingEvent) => void;
onSlotSelect?: (slotInfo: SlotInfo) => void;
selectedRoomId?: string | number;
officeStartHour?: number;
officeEndHour?: number;
initialDate?: string;
}
import type { SlotInfo, CalendarViewProps } from "../interfaces";
// Read business hours from env (default 08:00-18:00)
const businessStart = process.env.BUSINESS_START || "08:00";
@@ -72,22 +42,14 @@ const CalendarView: React.FC<CalendarViewProps> = ({
// Custom event content with tooltip
const renderEventContent = (arg: { event: BookingEvent }) => {
const { event } = arg;
const { title, start, end, resource } = event;
// Robust room name extraction
const room: string =
typeof resource?.room_name === "string" &&
resource.room_name.trim() !== ""
? resource.room_name
: typeof resource?.room_id === "string" &&
resource.room_id.trim() !== ""
? resource.room_id
: typeof resource?.room_id === "number"
? String(resource.room_id)
: resource?.originalBooking?.room?.name
? resource.originalBooking.room.name
: "Room";
const invitees: string[] =
resource && Array.isArray(resource.invitees) ? resource.invitees : [];
const title = event.title;
const start = event.start;
const end = event.end;
const room_id = event.extendedProps?.room_id;
const room: string = event.extendedProps?.room_name ?? "Room";
const invitees: string[] = Array.isArray(event.extendedProps?.invitees)
? (event.extendedProps?.invitees as string[])
: [];
const safeTitle: string = typeof title === "string" ? title : "";
const safeStart: Date | null = start
? new Date(start as string | number | Date)
@@ -100,7 +62,9 @@ const CalendarView: React.FC<CalendarViewProps> = ({
safeStart ?? new Date(),
room
);
const roomClass = getRoomClass(resource?.room_id ?? null);
const roomClass = getRoomClass(
typeof room_id === "number" ? room_id : parseInt(room_id as string, 10)
);
return (
<Tooltip
title={
@@ -147,7 +111,6 @@ const CalendarView: React.FC<CalendarViewProps> = ({
borderRadius: 4,
paddingLeft: 4,
paddingRight: 4,
background: undefined, // Remove inline background to allow CSS class to control color
}}
>
<span
@@ -205,13 +168,8 @@ const CalendarView: React.FC<CalendarViewProps> = ({
eventClick={(info) => {
info.jsEvent.preventDefault();
if (onEventClick) {
// Pass the full event object with custom properties
const eventObj =
info.event.extendedProps &&
Object.keys(info.event.extendedProps).length > 0
? { ...info.event, ...info.event.extendedProps }
: info.event;
onEventClick(eventObj as BookingEvent);
// Pass the full event object
onEventClick(info.event as unknown as BookingEvent);
}
}}
selectable={true}

View File

@@ -1,3 +1,13 @@
/**
* RoomDetailsModal.tsx
* Modal dialog for displaying details about a conference room.
* Used in booking page for room info popups.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
* @component
* @returns {JSX.Element} Room details modal UI
*/
/**
* RoomDetailsModal.tsx
* Modal dialog for displaying details about a conference room.
@@ -17,7 +27,7 @@ import {
Typography,
Box,
} from "@mui/material";
import type { RoomDetailsModalProps } from "../schemas";
import type { RoomDetailsModalProps } from "../interfaces";
const RoomDetailsModal: React.FC<RoomDetailsModalProps> = ({
open,

View File

@@ -1,14 +1,8 @@
/**
* RoomList.tsx
* RoomList component
* Displays a list of available conference rooms, highlighting the selected room.
* Used in the landing page for room selection.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
*/
/**
* RoomList component.
* Displays a list of available conference rooms. Selected room is highlighted.
* @returns {JSX.Element} Room list UI
*/
import React from "react";
@@ -24,7 +18,7 @@ import {
} from "@mui/material";
import MeetingRoomIcon from "@mui/icons-material/MeetingRoom";
import { getRoomListItemStyles, getRoomSecondaryText } from "../helpers/room";
import type { RoomListProps } from "../schemas";
import type { RoomListProps } from "../interfaces";
/**
* Renders a list of conference rooms with their details.

View File

@@ -1,8 +1,14 @@
// booking.ts
// Utility functions for booking-related logic in booking system frontend
/**
* booking.ts
* Utility functions for booking-related logic in booking system frontend.
* Provides helpers for extracting room IDs and handling booking objects.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
*/
// Minimal Booking type for utility functions
import type { Booking } from "../schemas";
import type { Booking } from "../interfaces";
/**
* Extracts the room ID from a booking object, handling multiple possible shapes.
@@ -10,51 +16,9 @@ import type { Booking } from "../schemas";
export function getEditBookingRoomId(
editBooking: Booking | undefined | null
): string {
if (!editBooking || typeof editBooking !== "object") return "";
let id: unknown = "";
// Safely check for non-standard properties using type guards
if ("_def" in editBooking && typeof editBooking._def === "object") {
const ext = (editBooking._def as any).extendedProps;
if (ext && ext.resource) {
id = ext.resource.room_id ?? ext.resource.id ?? id;
}
}
if (
!id &&
"resource" in editBooking &&
typeof (editBooking as any).resource === "object"
) {
id = (editBooking as any).resource.room_id ?? id;
id = (editBooking as any).resource.id ?? id;
}
if (!id && "room_id" in editBooking) {
id = (editBooking as any).room_id ?? id;
}
if (
!id &&
"room" in editBooking &&
typeof (editBooking as any).room === "object"
) {
id = (editBooking as any).room.id ?? id;
}
if (!id && "roomId" in editBooking) {
id = (editBooking as any).roomId ?? id;
}
// Fallback: search for any property containing 'room' and is a number
if (!id) {
for (const k of Object.keys(editBooking)) {
// @ts-ignore
if (
k.toLowerCase().includes("room") &&
typeof (editBooking as any)[k] === "number"
) {
// @ts-ignore
id = (editBooking as any)[k];
break;
}
}
}
return id !== undefined && id !== null ? String(id) : "";
if (!editBooking) return "";
// Only support standard Booking shape
return editBooking.room_id ? String(editBooking.room_id) : "";
}
/**

View File

@@ -1,12 +1,19 @@
// bookingList.ts
// Utility functions for BookingList component
/**
* bookingList.ts
* Utility functions for BookingList component.
* Provides helpers for sorting bookings and formatting invitee names.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
*/
/**
* Represents a booking object with a start_time property.
*/
import type { Booking as SharedBooking } from "../schemas";
import type { BookingType } from "../types";
import type { Invitee } from "../interfaces";
export type Booking = SharedBooking;
export type Booking = BookingType;
/**
* Sorts bookings by their start time (ascending).
@@ -39,12 +46,6 @@ export function formatBookingTime(dateStr: string): string {
/**
* Represents an invitee object.
*/
export interface Invitee {
name?: string;
username?: string;
displayName?: string;
user?: { name?: string };
}
/**
* Extracts the display name for an invitee.

View File

@@ -1,5 +1,11 @@
// calendar.ts
// Utility functions for CalendarView and booking system frontend
/**
* calendar.ts
* Utility functions for CalendarView and booking system frontend.
* Provides helpers for event display and room color assignment.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
*/
/**
* Returns display text for a calendar event, including start time and title/room.
@@ -37,16 +43,10 @@ export function getEventDisplayText(
/**
* Returns a CSS class for a room color, based on room ID.
* @param roomId - Room ID (string, number, or null)
* @param roomId - Room ID number
* @returns CSS class string
*/
export function getRoomClass(roomId: string | number | null): string {
let idx = 0;
if (typeof roomId === "number") {
idx = roomId % 8;
} else if (typeof roomId === "string" && roomId !== "") {
const parsed = parseInt(roomId, 10);
idx = isNaN(parsed) ? 0 : parsed % 8;
}
export function getRoomClass(roomId: number): string {
let idx = roomId ? roomId % 20 : 0;
return `room-color-${idx}`;
}

View File

@@ -1,19 +1,18 @@
// room.ts
// Utility functions for RoomList component
import { ConferenceRoom } from "../schemas";
/**
* room.ts
* Utility functions for RoomList component.
* Provides helpers for rendering room details and styles.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
*/
import { ConferenceRoom } from "../interfaces";
import type { RoomListItemStyles } from "../interfaces";
export function getRoomSecondaryText(room: ConferenceRoom): string {
return `Location: ${room.location} | Capacity: ${room.capacity} | Equipment: ${room.equipment}`;
}
interface RoomListItemStyles {
backgroundColor?: string;
fontWeight?: number;
color: string;
border?: string;
borderRadius?: number;
}
export function getRoomListItemStyles(
roomId: number,
selectedRoomId?: number

View File

@@ -1,7 +1,13 @@
// validation.ts
// Independent field validation helpers for BookingForm and booking system frontend
/**
* validation.ts
* Independent field validation helpers for BookingForm and booking system frontend.
* Provides functions for validating room, time, and invitee fields.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
*/
import type { Booking } from "../schemas";
import type { Booking } from "../interfaces";
/**
* Validates room ID field.

View File

@@ -3,7 +3,7 @@
* Global CSS for the React app, including font and code styles.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
* Last updated: 2025-09-08
*/
/*
* Global Styles

127
frontend/src/interfaces.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* interfaces.ts
* Centralized TypeScript interfaces for frontend application.
* Author: Cliff Hill
* Last updated: 2025-09-09
*/
/**
* SlotInfo for calendar and booking forms.
*/
export interface SlotInfo {
start: Date;
end: Date;
room_id?: string | number;
allDay?: boolean;
viewType?: string;
selectedRoomId?: string | number;
}
/**
* Props for CalendarView component.
*/
export interface CalendarViewProps {
events: import("./types").BookingEvent[];
onEventClick?: (event: import("./types").BookingEvent) => void;
onSlotSelect?: (slotInfo: SlotInfo) => void;
selectedRoomId?: string | number;
officeStartHour?: number;
officeEndHour?: number;
initialDate?: string;
}
/**
* Props for BookingForm component.
*/
export interface BookingFormProps {
open: boolean;
onClose: () => void;
slotInfo: SlotInfo;
rooms: ConferenceRoom[];
editBooking?: Booking;
allInvitees: string[];
onBookingSuccess?: () => void;
}
/**
* Invitee object for booking lists.
*/
export interface Invitee {
name?: string;
username?: string;
displayName?: string;
user?: { name?: string };
}
/**
* RoomListItemStyles for room list rendering.
*/
export interface RoomListItemStyles {
backgroundColor?: string;
fontWeight?: number;
color: string;
border?: string;
borderRadius?: number;
}
/**
* SSE payload for rooms availability stream.
*/
export interface RoomsAvailabilitySSEPayload {
bookings: Booking[];
[key: string]: unknown;
}
export interface User {
id: string | number;
name: string;
email?: string;
}
export interface Booking {
id: string;
room_id?: number | string;
room?: { name?: string };
start_time: string;
end_time: string;
start?: string | number | Date;
end?: string | number | Date;
title?: string;
invitees?: string[];
}
export interface ConferenceRoom {
id: number;
name: string;
location: string;
equipment: string;
capacity: number;
}
export interface BookingConfirmationProps {
booking: Booking & {
room?: { name?: string };
invitees?: string[];
start_time: string;
end_time: string;
title?: string;
};
onEdit: () => void;
onBack: () => void;
}
export interface RoomDetailsModalProps {
open: boolean;
onClose: () => void;
room: ConferenceRoom;
}
export interface BookingListProps {
bookings: Booking[];
onSelect?: (booking: Booking) => void;
}
export interface RoomListProps {
rooms: ConferenceRoom[];
selectedRoomId?: number;
onSelectRoom?: (roomId: number) => void;
}

View File

@@ -5,9 +5,11 @@
*
* Author: Cliff Hill
* Last updated: 2025-09-05
* @component
* @returns {JSX.Element} Booking page UI
*/
import React, { useMemo, useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Button,
@@ -26,58 +28,90 @@ import CalendarView from "../components/CalendarView";
import BookingForm from "../components/BookingForm";
import RoomDetailsModal from "../components/RoomDetailsModal";
import { logger } from "../utils/logger";
import type { Booking, ConferenceRoom, User } from "../interfaces";
import type { BookingEvent } from "../types";
/**
* BookingPage component
* Renders the booking calendar, handles room selection, booking creation, and SSE updates.
* @returns {JSX.Element} Booking page UI
*/
const BookingPage: React.FC = () => {
// State for booking form
const [formOpen, setFormOpen] = useState(false);
// State for room details modal (page-level)
const [roomModalOpen, setRoomModalOpen] = useState(false);
// Log mount
// Log mount/unmount for analytics
/**
* Track mount/unmount for analytics.
*/
useEffect(() => {
logger.info("[BookingPage] Mounted");
return () => logger.info("[BookingPage] Unmounted");
}, []);
// Fetch all rooms
/**
* Fetch all rooms from backend.
*/
const {
data: rooms = [],
isLoading: roomsLoading,
error: roomsError,
}: {
data?: ConferenceRoom[];
isLoading: boolean;
error?: unknown;
} = useQuery({
queryKey: ["rooms"],
queryFn: () => getRooms(),
});
// Real-time bookings state (by room)
const [bookingsByRoom, setBookingsByRoom] = useState<Record<number, any[]>>(
{}
);
/**
* Real-time bookings state, grouped by room id.
*/
const [bookingsByRoom, setBookingsByRoom] = useState<
Record<number, Booking[]>
>({});
// Fetch all users (invitees)
/**
* Fetch all users (invitees).
*/
const {
data: allInvitees = [],
isLoading: inviteesLoading,
error: inviteesError,
}: {
data?: import("../interfaces").User[];
isLoading: boolean;
error?: unknown;
} = useQuery({
queryKey: ["users"],
queryFn: () => getUsers(),
});
const [bookingsLoading, setBookingsLoading] = useState(true);
const [bookingsError, setBookingsError] = useState<any>(null);
const [bookingsError, setBookingsError] = useState<unknown>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const [usingPollingFallback, setUsingPollingFallback] = useState(false);
// Fetch initial bookings and subscribe to SSE
/**
* Fetch initial bookings and subscribe to SSE or polling fallback.
*/
useEffect(() => {
let cancelled = false;
let pollInterval: NodeJS.Timeout | null = null;
/**
* Fetch bookings for all rooms and update state.
*/
async function fetchBookingsAndSet() {
setBookingsLoading(true);
setBookingsError(null);
try {
const results: Record<number, any[]> = {};
const results: Record<number, Booking[]> = {};
await Promise.all(
rooms.map(async (room: any) => {
rooms.map(async (room: ConferenceRoom) => {
try {
const bookings = await getRoomBookings(room.id);
const bookings: Booking[] = await getRoomBookings(room.id);
results[room.id] = bookings;
logger.debug(
`[BookingPage] Bookings fetched for room ${room.id}`,
@@ -103,7 +137,7 @@ const BookingPage: React.FC = () => {
if (rooms.length > 0) {
fetchBookingsAndSet();
logger.info("[BookingPage] Fetching bookings for all rooms");
// Fallback: If EventSource/SSE is not supported, use polling
// Use SSE if available, otherwise fallback to polling
const sseAllowed =
typeof window !== "undefined" &&
typeof window.EventSource !== "undefined";
@@ -113,10 +147,11 @@ const BookingPage: React.FC = () => {
eventSourceRef.current = connectRoomsAvailabilityStream({
onMessage: (data) => {
if (Array.isArray(data.bookings)) {
const grouped: Record<number, any[]> = {};
data.bookings.forEach((b: any) => {
if (!grouped[b.room_id]) grouped[b.room_id] = [];
grouped[b.room_id].push(b);
const grouped: Record<number, Booking[]> = {};
data.bookings.forEach((b: Booking) => {
if (!grouped[b.room_id as number])
grouped[b.room_id as number] = [];
grouped[b.room_id as number].push(b);
});
setBookingsByRoom(grouped);
logger.debug("[BookingPage] SSE update received", grouped);
@@ -143,107 +178,84 @@ const BookingPage: React.FC = () => {
};
}, [rooms, formOpen, roomModalOpen]);
// Assign a color to each room
const roomColors: Record<string, string> = useMemo(() => {
const defaultColors = [
"#1976d2",
"#388e3c",
"#fbc02d",
"#d32f2f",
"#7b1fa2",
"#0288d1",
"#c2185b",
"#ffa000",
];
const colors: Record<string, string> = {};
rooms.forEach((room: any, idx: number) => {
colors[room.id] = defaultColors[idx % defaultColors.length];
});
return colors;
}, [rooms]);
// Removed unused roomColors variable
// Combine all bookings into calendar events, sorted chronologically (room id tiebreaker), with color and formatted title
// Expose allBookings for event lookup
const allBookings: any[] = React.useMemo(() => {
/**
* Flatten all bookings from all rooms into a single array.
*/
const allBookings: Booking[] = React.useMemo(() => {
if (!bookingsByRoom || rooms.length === 0) return [];
return Object.entries(bookingsByRoom).flatMap(
([roomId, bookings]) => bookings as any[]
([roomId, bookings]) => bookings as Booking[]
);
}, [bookingsByRoom, rooms]);
const events: any[] = React.useMemo(() => {
/**
* Map all bookings to calendar event objects for display.
*/
const events = React.useMemo(() => {
if (!bookingsByRoom || rooms.length === 0) return [];
// Map allBookings to calendar events
const mappedEvents = Object.entries(bookingsByRoom).flatMap(
([roomId, bookings]) =>
(bookings as any[]).map((booking) => {
(bookings as Booking[]).map((booking) => {
logger.info("[BookingPage] Event mapping diagnostic", {
bookingId: booking.id,
bookingRoomId: booking.room_id,
roomIdFromGroup: roomId,
});
// Normalize roomId for lookup
const normalizedRoomId = String(roomId);
const room = rooms.find(
(r: any) => String(r.id) === normalizedRoomId
(r: ConferenceRoom) => String(r.id) === normalizedRoomId
);
let roomName = room?.name;
if (!roomName || roomName.trim() === "") {
if (
typeof booking.room_name === "string" &&
booking.room_name.trim() !== ""
typeof booking.room?.name === "string" &&
booking.room?.name.trim() !== ""
) {
roomName = booking.room_name;
roomName = booking.room?.name;
} else if (
typeof booking.room_id === "string" &&
booking.room_id.trim() !== ""
) {
roomName = booking.room_id;
roomName = booking.room_id as string;
} else if (typeof booking.room_id === "number") {
roomName = String(booking.room_id);
} else {
roomName = "Room";
}
}
const eventColor = roomColors[normalizedRoomId] || "#1976d2";
const inviteeNames = booking.invitees ?? [];
const start = new Date(booking.start_time);
const end = new Date(booking.end_time);
const displayTitle =
booking.title && booking.title.trim() !== ""
? booking.title
: roomName;
let inviteeNames: string[] = [];
if (Array.isArray(booking.invitees)) {
inviteeNames = booking.invitees
.map((inv: any) => inv?.user?.name)
.filter((name: string | undefined) => !!name);
}
return {
id: booking.id,
title: displayTitle,
start,
end,
color: eventColor,
backgroundColor: eventColor,
resource: {
room_id: booking.room_id,
room_name: roomName,
invitees: inviteeNames,
originalBooking: booking,
},
title: booking.title ?? "",
start: start ?? new Date(booking.start_time),
end: end ?? new Date(booking.end_time),
room_id: booking.room_id,
room_name: roomName,
invitees: inviteeNames,
originalBooking: booking,
};
})
);
mappedEvents.sort((a, b) => {
const aStart = a.start.getTime();
const bStart = b.start.getTime();
if (aStart !== bStart) return aStart - bStart;
return String(a.resource.room_id).localeCompare(
String(b.resource.room_id)
);
});
return mappedEvents;
}, [bookingsByRoom, rooms, roomColors]);
}, [bookingsByRoom, rooms]);
// Removed duplicate and broken code block
const [formSlot, setFormSlot] = useState<any>(null);
const [editBooking, setEditBooking] = useState<any>(null);
interface SlotInfo {
start: Date;
end: Date;
room_id?: string | number;
}
const [formSlot, setFormSlot] = useState<SlotInfo | null>(null);
const [editBooking, setEditBooking] = useState<Booking | null>(null);
// Default to first room, always require a room to be selected
const [selectedRoomId, setSelectedRoomId] = useState<any>("");
const [selectedRoomId, setSelectedRoomId] = useState<string | number>("");
// Set default room when rooms load
useEffect(() => {
@@ -253,7 +265,9 @@ const BookingPage: React.FC = () => {
}, [rooms, selectedRoomId]);
// Handle slot selection from calendar (month/week/day)
const handleSelectSlot = (slotInfo: any) => {
const handleSelectSlot = (
slotInfo: SlotInfo & { viewType?: string; start?: Date | string }
) => {
logger.debug(
"[BookingPage] Slot selected",
slotInfo,
@@ -311,7 +325,7 @@ const BookingPage: React.FC = () => {
// 2. Find all bookings for this room on this day
const roomBookings = events.filter((e) => {
if (e.resource?.room_id !== selectedRoomId) return false;
if (e.room_id !== selectedRoomId) return false;
const eventStart =
e.start &&
(typeof e.start === "string" ||
@@ -381,23 +395,27 @@ const BookingPage: React.FC = () => {
setFormOpen(true);
};
const handleSelectEvent = (event: any) => {
const originalBooking = event.resource?.originalBooking;
const handleSelectEvent = (event: BookingEvent) => {
const originalBooking = event.originalBooking;
const eventIdStr = String(event.id);
if (originalBooking && originalBooking.id) {
setEditBooking(originalBooking);
} else {
// Try to find backend booking by event.id using allBookings (compare as strings)
const foundBooking = allBookings.find(
(b: any) => String(b.id) === eventIdStr
(b: Booking) => String(b.id) === eventIdStr
);
if (foundBooking) {
setEditBooking(foundBooking);
} else {
setEditBooking({
...event,
start_time: event.start,
end_time: event.end,
id: String(event.id),
room_id: event.room_id,
room: event.room_name ? { name: event.room_name } : undefined,
start_time: event.start ? event.start.toString() : "",
end_time: event.end ? event.end.toString() : "",
title: event.title ?? "",
invitees: event.invitees ?? [],
});
}
}
@@ -408,10 +426,10 @@ const BookingPage: React.FC = () => {
const refetchBookings = async () => {
setBookingsLoading(true);
setBookingsError(null);
const results: Record<number, any[]> = {};
const results: Record<number, Booking[]> = {};
try {
await Promise.all(
rooms.map(async (room: any) => {
rooms.map(async (room: ConferenceRoom) => {
try {
const bookings = await getRoomBookings(room.id);
results[room.id] = bookings;
@@ -488,7 +506,7 @@ const BookingPage: React.FC = () => {
label="Room"
onChange={(e) => setSelectedRoomId(e.target.value)}
>
{rooms.map((room: any) => (
{rooms.map((room: ConferenceRoom) => (
<MenuItem key={room.id} value={room.id}>
{room.name}
</MenuItem>
@@ -507,11 +525,13 @@ const BookingPage: React.FC = () => {
<RoomDetailsModal
open={roomModalOpen}
onClose={() => setRoomModalOpen(false)}
room={rooms.find((r: any) => r.id === selectedRoomId)}
room={
rooms.find((r: ConferenceRoom) => r.id === selectedRoomId) ?? rooms[0]
}
/>
<CalendarView
events={events as any}
onEventClick={(event: any) => handleSelectEvent(event)}
events={events}
onEventClick={handleSelectEvent}
onSlotSelect={handleSelectSlot}
selectedRoomId={selectedRoomId}
/>
@@ -519,10 +539,18 @@ const BookingPage: React.FC = () => {
<BookingForm
open={formOpen}
onClose={() => handleFormClose(true)}
slotInfo={formSlot}
slotInfo={
formSlot ?? {
start: new Date(),
end: new Date(),
room_id: selectedRoomId,
}
}
rooms={rooms}
editBooking={editBooking}
allInvitees={allInvitees.map((u: any) => u.email)}
editBooking={editBooking ?? undefined}
allInvitees={allInvitees.map(
(u: import("../interfaces").User) => u.email ?? u.name
)}
onBookingSuccess={() => handleFormClose(true)}
/>
)}

View File

@@ -5,9 +5,12 @@
*
* Author: Cliff Hill
* Last updated: 2025-09-05
* @component
* @returns {JSX.Element} Confirmation page UI
*/
import React, { useState } from "react";
import type { Booking } from "../interfaces";
import { Box } from "@mui/material";
import { useNavigate, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
@@ -16,23 +19,38 @@ import { connectRoomsAvailabilityStream } from "../apis/sse";
import BookingConfirmation from "../components/BookingConfirmation";
import BookingForm from "../components/BookingForm";
// This page expects booking data to be passed via location.state
/**
* ConfirmationPage component
* Shows booking confirmation and allows editing or returning to booking form.
* Expects booking data via location.state.
* @returns {JSX.Element} Confirmation page UI
*/
const ConfirmationPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
// booking is passed from BookingForm or BookingPage
const [booking, setBooking] = useState<any>(location.state?.booking || null);
// Subscribe to SSE for live booking status
/**
* Booking state, updated via SSE for live status.
*/
const [booking, setBooking] = useState<Booking | null>(
location.state?.booking || null
);
/**
* Subscribe to SSE for live booking status updates.
*/
React.useEffect(() => {
if (!booking) return;
const es = connectRoomsAvailabilityStream({
onMessage: (data) => {
if (Array.isArray(data.bookings)) {
// Find the current booking by id
const updated = data.bookings.find((b: any) => b.id === booking.id);
const updated = data.bookings.find(
(b: Booking) => b.id === booking?.id
);
if (updated) {
setBooking((prev: any) => ({ ...prev, ...updated }));
setBooking((prev: Booking | null) =>
prev ? { ...prev, ...updated } : updated
);
}
}
},
@@ -44,9 +62,15 @@ const ConfirmationPage: React.FC = () => {
es.close();
};
}, [booking, booking?.id]);
/**
* Editing state for toggling between confirmation and edit form.
*/
const [editing, setEditing] = useState(false);
// Fetch all rooms for editing
/**
* Fetch all rooms for editing.
*/
const {
data: rooms = [],
isLoading: roomsLoading,
@@ -88,7 +112,7 @@ const ConfirmationPage: React.FC = () => {
slotInfo={{
start: booking.start_time,
end: booking.end_time,
room_id: booking.room?.id,
room_id: booking.room_id,
}}
rooms={rooms}
editBooking={{

View File

@@ -6,6 +6,8 @@
*
* Author: Cliff Hill
* Last updated: 2025-09-05
* @component
* @returns {JSX.Element} Landing page UI
*/
import React, { FC, useEffect, useState } from "react";
@@ -24,8 +26,8 @@ import { getRooms } from "../apis/rooms";
import { connectRoomsAvailabilityStream } from "../apis/sse";
import RoomList from "../components/RoomList";
import BookingList from "../components/BookingList";
import type { ConferenceRoom } from "../schemas";
import type { Booking } from "../schemas";
import type { ConferenceRoom } from "../interfaces";
import type { Booking } from "../interfaces";
import { logger } from "../utils/logger";
// import "./LandingPage.css";
@@ -37,8 +39,17 @@ import { logger } from "../utils/logger";
* - Navigation to booking form
* Handles data fetching, error states, and loading skeletons.
*/
/**
* LandingPage component
* Renders the main UI for conference room booking, including available rooms, today's bookings, and navigation to booking form.
* Handles data fetching, error states, and loading skeletons.
* @returns {JSX.Element} Landing page UI
*/
const LandingPage: FC = () => {
// Log when LandingPage mounts/unmounts
/**
* Log when LandingPage mounts/unmounts.
*/
useEffect(() => {
logger.info("[LandingPage] Mounted");
return () => {
@@ -47,6 +58,9 @@ const LandingPage: FC = () => {
}, []);
// Debug: log before page unload
/**
* Debug: log before page unload.
*/
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
// ...removed debug log...
@@ -57,6 +71,9 @@ const LandingPage: FC = () => {
const navigate = useNavigate();
// Fetch available rooms from backend
/**
* Fetch available rooms from backend.
*/
const {
data: rooms = [],
isLoading: roomsLoading,
@@ -99,9 +116,11 @@ const LandingPage: FC = () => {
onMessage: (data) => {
if (Array.isArray(data.bookings)) {
const grouped: Record<number, Booking[]> = {};
data.bookings.forEach((b: any) => {
if (!grouped[b.room_id]) grouped[b.room_id] = [];
grouped[b.room_id].push(b);
data.bookings.forEach((b: Booking) => {
if (typeof b.room_id === "undefined" || b.room_id === null) return;
const roomId = Number(b.room_id);
if (!grouped[roomId]) grouped[roomId] = [];
grouped[roomId].push(b);
});
setBookingsByRoom(grouped);
logger.debug("[LandingPage] SSE update received", grouped);

View File

@@ -1 +1,8 @@
/**
* react-app-env.d.ts
* TypeScript environment declarations for React scripts.
*
* Author: Cliff Hill
* Last updated: 2025-09-08
*/
/// <reference types="react-scripts" />

View File

@@ -1,3 +1,10 @@
/**
* reportWebVitals.ts
* Web Vitals performance reporting for React app.
*
* Author: Cliff Hill
* Last updated: 2025-09-08
*/
import { ReportHandler } from "web-vitals";
const reportWebVitals = (onPerfEntry?: ReportHandler) => {

View File

@@ -1,3 +1,11 @@
/**
* Represents a user/invitee.
*/
export interface User {
id: string | number;
name: string;
email?: string;
}
/**
* schemas.ts
* Centralized TypeScript interfaces for backend data schemas used in the frontend.
@@ -11,9 +19,12 @@
*/
export interface Booking {
id: string;
room_id?: number | string;
room?: { name?: string };
start_time: string;
end_time: string;
start?: string | number | Date;
end?: string | number | Date;
title?: string;
invitees?: string[];
}
@@ -50,7 +61,7 @@ export interface BookingConfirmationProps {
export interface RoomDetailsModalProps {
open: boolean;
onClose: () => void;
room: ConferenceRoom | any;
room: ConferenceRoom;
}
/**

View File

@@ -1,3 +1,10 @@
/**
* setupTests.ts
* Jest setup for custom matchers and DOM assertions.
*
* Author: Cliff Hill
* Last updated: 2025-09-08
*/
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)

View File

@@ -1,4 +1,9 @@
/* BookingForm.css */
/*
* BookingForm.css
* Styles for BookingForm component.
* Author: Cliff Hill
* Last updated: 2025-09-08
*/
.booking-form-room-label {
display: flex;
align-items: center;

View File

@@ -1,4 +1,9 @@
/* BookingList.css */
/*
* BookingList.css
* Styles for BookingList component.
* Author: Cliff Hill
* Last updated: 2025-09-08
*/
.booking-list {
margin-top: 0.5em;
}

View File

@@ -1,18 +1,22 @@
/*
* CalendarView.css
* Styles for the calendar view component, including event colors and non-business hours.
* Styles for the calendar view component, including event colors, business hours, and layout.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
*/
/* Non-business hours styling for FullCalendar */
/* =============================== */
/* Non-business hours styling */
/* =============================== */
.fc-timegrid-col-bg .fc-non-business,
.fc-non-business {
background: #888 !important;
opacity: 0.5 !important;
}
/* Truncate and ellipsis for event content and titles */
/* =============================== */
/* Event content truncation */
/* =============================== */
.fc-event-title-ellipsis,
.fc-event .fc-event-main > div,
.fc-event-title,
@@ -33,7 +37,9 @@
min-width: 0;
}
/* Weekend all-day and time slots: unified non-business color, lighter border */
/* =============================== */
/* Weekend and weekday styling */
/* =============================== */
.fc .fc-daygrid-day.fc-day-sat .fc-daygrid-day-frame,
.fc .fc-daygrid-day.fc-day-sun .fc-daygrid-day-frame,
.fc .fc-timegrid-col.fc-day-sat,
@@ -49,13 +55,13 @@
opacity: 0.7 !important;
}
/* Weekday non-business hours: same as weekend background */
/* Weekday non-business hours */
.fc .fc-non-business {
background: #888 !important;
opacity: 0.5 !important;
}
/* Workday all-day and time slots: match all-day workday background */
/* Workday all-day and time slots */
.fc .fc-daygrid-day .fc-daygrid-day-frame,
.fc .fc-timegrid-col {
background: #c0c0c0 !important;
@@ -67,12 +73,14 @@
border-color: #bbb !important;
}
/* Out-of-month days: distinct but not too dark */
/* Out-of-month days styling */
.fc .fc-daygrid-day.fc-day-other .fc-daygrid-day-frame {
background: #aaa !important;
opacity: 1 !important;
}
/* Calendar cell backgrounds use CSS variables */
/* =============================== */
/* Calendar cell backgrounds */
/* =============================== */
:root {
--fc-page-bg-color: #a0a0a0;
--fc-neutral-bg-color: #e3f2fd;
@@ -144,7 +152,6 @@
}
.fc .fc-event {
background: #1976d2 !important;
color: #fff !important;
padding: 2px 6px !important;
font-size: 0.95rem !important;
@@ -191,3 +198,96 @@
.fc-toolbar.fc-header-toolbar {
margin-bottom: 0px !important;
}
/* =============================== */
/* Room color classes for events */
/* =============================== */
/*
* .room-color-0 to .room-color-19
* Used to assign unique background colors to events based on room ID.
* These classes are mapped in the frontend logic (getRoomClass).
*/
.room-color-0 {
background: #1976d2 !important;
}
.room-color-1 {
background: #388e3c !important;
}
.room-color-2 {
background: #fbc02d !important;
}
.room-color-3 {
background: #d32f2f !important;
}
.room-color-4 {
background: #7b1fa2 !important;
}
.room-color-5 {
background: #0288d1 !important;
}
.room-color-6 {
background: #c2185b !important;
}
.room-color-7 {
background: #ffa000 !important;
}
.room-color-8 {
background: #009688 !important;
}
.room-color-9 {
background: #8bc34a !important;
}
.room-color-10 {
background: #e91e63 !important;
}
.room-color-11 {
background: #00bcd4 !important;
}
.room-color-12 {
background: #ff5722 !important;
}
.room-color-13 {
background: #9c27b0 !important;
}
.room-color-14 {
background: #3f51b5 !important;
}
.room-color-15 {
background: #4caf50 !important;
}
.room-color-16 {
background: #ff9800 !important;
}
.room-color-17 {
background: #607d8b !important;
}
.room-color-18 {
background: #795548 !important;
}
.room-color-19 {
background: #b71c1c !important;
}
/* Ensure event text is readable on colored backgrounds */
.room-color-0,
.room-color-1,
.room-color-2,
.room-color-3,
.room-color-4,
.room-color-5,
.room-color-6,
.room-color-7,
.room-color-8,
.room-color-9,
.room-color-10,
.room-color-11,
.room-color-12,
.room-color-13,
.room-color-14,
.room-color-15,
.room-color-16,
.room-color-17,
.room-color-18,
.room-color-19 {
color: #fff !important;
}

View File

@@ -1,4 +1,9 @@
/* CalendarViewInline.css */
/*
* CalendarViewInline.css
* Inline styles for CalendarView component.
* Author: Cliff Hill
* Last updated: 2025-09-08
*/
.calendar-event-content {
width: 100%;
height: 100%;

View File

@@ -4,7 +4,7 @@
* Includes layout, card, and button styles for rooms and bookings.
*
* Author: Cliff Hill
* Last updated: 2025-09-05
* Last updated: 2025-09-08
*/
/* Force selected available room to have a visible background */
.MuiButtonBase-root.MuiListItemButton-root.Mui-selected {

View File

@@ -1,4 +1,9 @@
/* RoomList.css */
/*
* RoomList.css
* Styles for RoomList component.
* Author: Cliff Hill
* Last updated: 2025-09-08
*/
.room-list-card {
height: 100%;
display: flex;

View File

@@ -1,3 +1,10 @@
/**
* theme.ts
* MUI theme configuration for the frontend.
*
* Author: Cliff Hill
* Last updated: 2025-09-08
*/
import { createTheme } from "@mui/material/styles";
const theme = createTheme({

32
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* types.ts
* Centralized TypeScript types for frontend application.
* Author: Cliff Hill
* Last updated: 2025-09-09
*/
/**
* BookingEvent type for calendar events.
*/
export type BookingEvent = {
originalBooking?: import("./interfaces").Booking;
id: string | number;
title: string;
start: Date | string;
end: Date | string;
color?: string;
room_id?: string | number;
room_name?: string;
invitees?: string[];
extendedProps?: {
room_id?: string | number;
room_name?: string;
invitees?: string[];
[key: string]: unknown;
};
};
/**
* Booking type alias for shared usage.
*/
export type BookingType = import("./interfaces").Booking;

View File

@@ -1,5 +1,10 @@
// date.ts
// Utility functions for date and time formatting and manipulation
/**
* date.ts
* Utility functions for date and time formatting and manipulation.
*
* Author: Cliff Hill
* Last updated: 2025-09-08
*/
export function formatLocalDateTimeInput(date: Date): string {
function pad(n: number): string {

View File

@@ -1,5 +1,10 @@
// src/utils/logger.ts
// Centralized logger using loglevel, with level set from FRONTEND_LOG_LEVEL
/**
* logger.ts
* Centralized logger using loglevel, with level set from FRONTEND_LOG_LEVEL.
*
* Author: Cliff Hill
* Last updated: 2025-09-08
*/
import log, { LogLevelDesc } from "loglevel";
const level = (process.env.FRONTEND_LOG_LEVEL || "info") as LogLevelDesc;