This commit is contained in:
Joshua Schmucker
2026-01-30 14:32:53 +01:00
parent 941bc7e5e4
commit bf4136b8dc
22 changed files with 3090 additions and 76 deletions
+15 -33
View File
@@ -1,38 +1,20 @@
.App {
text-align: center;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f6fa;
color: #2c3e50;
line-height: 1.6;
}
.App-logo {
height: 40vmin;
pointer-events: none;
* {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+22 -17
View File
@@ -1,25 +1,30 @@
import React from 'react';
import logo from './logo.svg';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout';
import ErrorBoundary from './components/ErrorBoundary';
import ResourcesList from './pages/ResourcesList';
import ResourceCalendar from './pages/ResourceCalendar';
import CreateReservation from './pages/CreateReservation';
import UserDashboard from './pages/UserDashboard';
import AdminDashboard from './pages/AdminDashboard';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
<Router>
<ErrorBoundary>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<UserDashboard />} />
<Route path="/resources" element={<ResourcesList />} />
<Route path="/resources/:id/calendar" element={<ResourceCalendar />} />
<Route path="/reservations/new" element={<CreateReservation />} />
<Route path="/admin" element={<AdminDashboard />} />
</Routes>
</Layout>
</ErrorBoundary>
</Router>
);
}
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends React.Component<
React.PropsWithChildren<{}>,
ErrorBoundaryState
> {
constructor(props: React.PropsWithChildren<{}>) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div style={{
padding: '2rem',
textAlign: 'center',
backgroundColor: '#f8d7da',
color: '#721c24',
margin: '1rem',
borderRadius: '4px',
border: '1px solid #f5c6cb'
}}>
<h2>Something went wrong</h2>
<p>The application encountered an error. Please try refreshing the page.</p>
<details style={{ marginTop: '1rem', textAlign: 'left' }}>
<summary>Error details</summary>
<pre style={{
background: '#f8f9fa',
padding: '1rem',
borderRadius: '4px',
overflow: 'auto',
fontSize: '0.8rem',
marginTop: '0.5rem'
}}>
{this.state.error?.stack}
</pre>
</details>
<button
onClick={() => window.location.reload()}
style={{
marginTop: '1rem',
padding: '0.75rem 1.5rem',
backgroundColor: '#e74c3c',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Refresh Page
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
+218
View File
@@ -0,0 +1,218 @@
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background-color: #2c3e50;
color: white;
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: relative;
}
.nav-brand {
text-decoration: none;
color: white;
transition: background-color 0.2s;
border-radius: 4px;
padding: 0.25rem 0.5rem;
}
.nav-brand:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.nav-brand h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
/* Desktop Navigation */
.nav-links-desktop {
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 2rem;
}
/* Mobile Menu Button */
.mobile-menu-btn {
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
width: 32px;
height: 32px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s;
}
.mobile-menu-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.hamburger-line {
width: 20px;
height: 2px;
background-color: white;
margin: 2px 0;
transition: all 0.3s ease;
transform-origin: center;
}
.mobile-menu-btn[aria-expanded="true"] .hamburger-line:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.mobile-menu-btn[aria-expanded="true"] .hamburger-line:nth-child(2) {
opacity: 0;
transform: translateX(10px);
}
.mobile-menu-btn[aria-expanded="true"] .hamburger-line:nth-child(3) {
transform: rotate(-45deg) translate(5px, -5px);
}
/* Mobile Menu */
.mobile-menu {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
}
.mobile-menu-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.nav-links-mobile {
position: absolute;
top: 0;
right: 0;
background-color: #2c3e50;
width: 280px;
height: 100vh;
list-style: none;
margin: 0;
padding: 0;
transform: translateX(100%);
transition: transform 0.3s ease;
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
}
.mobile-menu.open .nav-links-mobile {
transform: translateX(0);
}
.nav-links-mobile li {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.nav-links-mobile li:last-child {
border-bottom: none;
}
/* Common Navigation Styles */
.nav-links a {
color: white;
text-decoration: none;
padding: 1rem 1.5rem;
border-radius: 4px;
transition: background-color 0.2s;
display: block;
font-size: 1rem;
}
.nav-links-desktop a {
padding: 0.5rem 1rem;
}
.nav-links a:hover,
.nav-links a.active {
background-color: #34495e;
}
.nav-brand:hover {
background-color: #34495e;
}
.main-content {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* Responsive Design */
@media (max-width: 768px) {
.navbar {
padding: 1rem;
}
.nav-brand h1 {
font-size: 1.1rem;
}
.nav-links-desktop {
display: none;
}
.mobile-menu-btn {
display: flex;
}
.mobile-menu {
display: block;
}
.main-content {
padding: 1rem;
}
}
@media (max-width: 480px) {
.navbar {
padding: 0.75rem 1rem;
}
.nav-brand h1 {
font-size: 1rem;
}
.mobile-menu-btn {
width: 28px;
height: 28px;
}
.hamburger-line {
width: 18px;
height: 2px;
}
.nav-links-mobile {
width: 250px;
}
.main-content {
padding: 0.75rem;
}
}
+112
View File
@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import './Layout.css';
interface LayoutProps {
children: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const toggleMobileMenu = () => {
try {
setIsMobileMenuOpen(!isMobileMenuOpen);
} catch (error) {
console.error('Error toggling mobile menu:', error);
}
};
const closeMobileMenu = () => {
try {
setIsMobileMenuOpen(false);
} catch (error) {
console.error('Error closing mobile menu:', error);
}
};
const navLinks = [
{
to: '/dashboard',
label: 'My Reservations',
isActive: location.pathname === '/dashboard'
},
{
to: '/resources',
label: 'Resources',
isActive: location.pathname.startsWith('/resources')
},
{
to: '/reservations/new',
label: 'Book Resource',
isActive: location.pathname === '/reservations/new'
},
{
to: '/admin',
label: 'Admin',
isActive: location.pathname === '/admin'
}
];
return (
<div className="layout">
<nav className="navbar">
<Link to="/dashboard" className="nav-brand" onClick={closeMobileMenu}>
<h1>LibreBooking UI</h1>
</Link>
{/* Mobile Menu Button */}
<button
className="mobile-menu-btn"
onClick={toggleMobileMenu}
aria-label="Toggle navigation menu"
aria-expanded={isMobileMenuOpen}
>
<span className="hamburger-line"></span>
<span className="hamburger-line"></span>
<span className="hamburger-line"></span>
</button>
{/* Desktop Navigation */}
<ul className="nav-links nav-links-desktop">
{navLinks.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className={link.isActive ? 'active' : ''}
>
{link.label}
</Link>
</li>
))}
</ul>
{/* Mobile Navigation */}
{isMobileMenuOpen && (
<div className="mobile-menu open">
<div className="mobile-menu-backdrop" onClick={closeMobileMenu}></div>
<ul className="nav-links nav-links-mobile">
{navLinks.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className={link.isActive ? 'active' : ''}
onClick={closeMobileMenu}
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
)}
</nav>
<main className="main-content">
{children}
</main>
</div>
);
};
export default Layout;
+217
View File
@@ -0,0 +1,217 @@
.admin-dashboard {
width: 100%;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.page-header p {
margin: 0;
color: #7f8c8d;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}
.stat-card h3 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
color: #3498db;
}
.stat-card p {
margin: 0;
color: #7f8c8d;
font-weight: 500;
}
.reservations-sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
.reservations-section {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.reservations-section h2 {
margin: 0;
padding: 1.5rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
color: #2c3e50;
}
.reservations-list {
padding: 1rem;
}
.reservation-card {
border: 1px solid #dee2e6;
border-radius: 6px;
margin-bottom: 1rem;
padding: 1.5rem;
background: white;
transition: box-shadow 0.2s;
}
.reservation-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.reservation-card:last-child {
margin-bottom: 0;
}
.reservation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.reservation-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.2rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
color: white;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.reservation-details {
display: grid;
gap: 0.5rem;
margin-bottom: 1rem;
}
.detail-row {
display: flex;
gap: 0.5rem;
color: #555;
font-size: 0.9rem;
}
.detail-row strong {
color: #2c3e50;
min-width: 80px;
}
.reservation-actions {
display: flex;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
font-weight: 500;
text-decoration: none;
text-align: center;
display: inline-block;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-confirm {
background-color: #27ae60;
color: white;
}
.btn-confirm:hover:not(:disabled) {
background-color: #229954;
}
.btn-cancel {
background-color: #e74c3c;
color: white;
}
.btn-cancel:hover:not(:disabled) {
background-color: #c0392b;
}
.no-reservations {
text-align: center;
color: #7f8c8d;
padding: 2rem;
font-style: italic;
}
.loading,
.error {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
}
.error {
color: #e74c3c;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.reservation-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.reservation-actions {
flex-direction: column;
}
.detail-row {
flex-direction: column;
gap: 0.25rem;
}
.detail-row strong {
min-width: auto;
}
}
+226
View File
@@ -0,0 +1,226 @@
import React, { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { api } from '../services/api';
import { Reservation } from '../types';
import './AdminDashboard.css';
const AdminDashboard: React.FC = () => {
const [reservations, setReservations] = useState<Reservation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState<string | null>(null);
useEffect(() => {
loadReservations();
}, []);
const loadReservations = async () => {
try {
setLoading(true);
const data = await api.getReservations();
// Sort by creation date, newest first
const sortedData = data.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setReservations(sortedData);
} catch (err) {
setError('Failed to load reservations');
} finally {
setLoading(false);
}
};
const handleConfirmReservation = async (reservationId: string) => {
try {
setProcessing(reservationId);
await api.updateReservationStatus(reservationId, 'confirmed');
// Update local state
setReservations(prev =>
prev.map(r =>
r.id === reservationId
? { ...r, status: 'confirmed' as const }
: r
)
);
} catch (err) {
setError('Failed to confirm reservation');
} finally {
setProcessing(null);
}
};
const handleCancelReservation = async (reservationId: string) => {
try {
setProcessing(reservationId);
await api.updateReservationStatus(reservationId, 'cancelled');
// Update local state
setReservations(prev =>
prev.map(r =>
r.id === reservationId
? { ...r, status: 'cancelled' as const }
: r
)
);
} catch (err) {
setError('Failed to cancel reservation');
} finally {
setProcessing(null);
}
};
const pendingReservations = reservations.filter(r => r.status === 'pending');
const confirmedReservations = reservations.filter(r => r.status === 'confirmed');
const cancelledReservations = reservations.filter(r => r.status === 'cancelled');
if (loading) {
return <div className="loading">Loading admin dashboard...</div>;
}
return (
<div className="admin-dashboard">
<div className="page-header">
<h1>Admin Dashboard</h1>
<p>Manage resource reservations</p>
</div>
{error && <div className="error">{error}</div>}
<div className="stats-grid">
<div className="stat-card">
<h3>{pendingReservations.length}</h3>
<p>Pending Reservations</p>
</div>
<div className="stat-card">
<h3>{confirmedReservations.length}</h3>
<p>Confirmed Reservations</p>
</div>
<div className="stat-card">
<h3>{cancelledReservations.length}</h3>
<p>Cancelled Reservations</p>
</div>
</div>
<div className="reservations-sections">
{pendingReservations.length > 0 && (
<section className="reservations-section">
<h2>Pending Reservations</h2>
<div className="reservations-list">
{pendingReservations.map(reservation => (
<ReservationCard
key={reservation.id}
reservation={reservation}
onConfirm={() => handleConfirmReservation(reservation.id)}
onCancel={() => handleCancelReservation(reservation.id)}
processing={processing === reservation.id}
/>
))}
</div>
</section>
)}
<section className="reservations-section">
<h2>All Reservations</h2>
<div className="reservations-list">
{reservations.length === 0 ? (
<p className="no-reservations">No reservations found</p>
) : (
reservations.map(reservation => (
<ReservationCard
key={reservation.id}
reservation={reservation}
onConfirm={reservation.status === 'pending' ? () => handleConfirmReservation(reservation.id) : undefined}
onCancel={reservation.status === 'pending' ? () => handleCancelReservation(reservation.id) : undefined}
processing={processing === reservation.id}
/>
))
)}
</div>
</section>
</div>
</div>
);
};
interface ReservationCardProps {
reservation: Reservation;
onConfirm?: () => void;
onCancel?: () => void;
processing: boolean;
}
const ReservationCard: React.FC<ReservationCardProps> = ({
reservation,
onConfirm,
onCancel,
processing
}) => {
const statusColors = {
pending: '#f39c12',
confirmed: '#27ae60',
cancelled: '#e74c3c'
};
return (
<div className="reservation-card">
<div className="reservation-header">
<h3>{reservation.title}</h3>
<span
className="status-badge"
style={{ backgroundColor: statusColors[reservation.status] }}
>
{reservation.status}
</span>
</div>
<div className="reservation-details">
<div className="detail-row">
<strong>Resource:</strong> {reservation.resource.name}
</div>
<div className="detail-row">
<strong>Location:</strong> {reservation.resource.location}
</div>
<div className="detail-row">
<strong>Date:</strong> {format(new Date(reservation.startTime), 'MMMM d, yyyy')}
</div>
<div className="detail-row">
<strong>Time:</strong> {format(new Date(reservation.startTime), 'h:mm a')} - {format(new Date(reservation.endTime), 'h:mm a')}
</div>
{reservation.description && (
<div className="detail-row">
<strong>Description:</strong> {reservation.description}
</div>
)}
<div className="detail-row">
<strong>Created:</strong> {format(new Date(reservation.createdAt), 'MMM d, yyyy h:mm a')}
</div>
</div>
{(onConfirm || onCancel) && (
<div className="reservation-actions">
{onConfirm && (
<button
className="btn btn-confirm"
onClick={onConfirm}
disabled={processing}
>
{processing ? 'Processing...' : 'Confirm'}
</button>
)}
{onCancel && (
<button
className="btn btn-cancel"
onClick={onCancel}
disabled={processing}
>
{processing ? 'Processing...' : 'Cancel'}
</button>
)}
</div>
)}
</div>
);
};
export default AdminDashboard;
+142
View File
@@ -0,0 +1,142 @@
.create-reservation {
max-width: 600px;
margin: 0 auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
margin: 0;
color: #2c3e50;
}
.reservation-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #2c3e50;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #3498db;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #eee;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
text-decoration: none;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
font-weight: 500;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #7f8c8d;
}
.error {
background-color: #f8d7da;
color: #721c24;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
border: 1px solid #f5c6cb;
}
.loading,
.no-resources {
text-align: center;
padding: 2rem;
color: #7f8c8d;
}
.no-resources h2 {
color: #2c3e50;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.create-reservation {
max-width: none;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
.reservation-form {
padding: 1.5rem;
}
}
+189
View File
@@ -0,0 +1,189 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { api } from '../services/api';
import { Resource, CreateReservationRequest } from '../types';
import './CreateReservation.css';
const CreateReservation: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [resources, setResources] = useState<Resource[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const preselectedResourceId = searchParams.get('resourceId');
const [formData, setFormData] = useState<CreateReservationRequest>({
resourceId: preselectedResourceId || '',
startTime: new Date(),
endTime: new Date(),
title: '',
description: '',
});
useEffect(() => {
loadResources();
}, []);
const loadResources = async () => {
try {
setLoading(true);
const data = await api.getResources();
const availableResources = data.filter(r => r.isAvailable);
setResources(availableResources);
// If no preselected resource but we have available ones, select the first
if (!preselectedResourceId && availableResources.length > 0) {
setFormData(prev => ({ ...prev, resourceId: availableResources[0].id }));
}
} catch (err) {
setError('Failed to load resources');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.resourceId || !formData.title) {
setError('Please fill in all required fields');
return;
}
if (formData.startTime >= formData.endTime) {
setError('End time must be after start time');
return;
}
try {
setSubmitting(true);
setError(null);
await api.createReservation(formData);
// Navigate to resources list on success
navigate('/resources', {
state: { message: 'Reservation created successfully!' }
});
} catch (err) {
setError('Failed to create reservation');
} finally {
setSubmitting(false);
}
};
const handleInputChange = (field: keyof CreateReservationRequest, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
if (loading) {
return <div className="loading">Loading resources...</div>;
}
if (resources.length === 0) {
return (
<div className="no-resources">
<h2>No Available Resources</h2>
<p>There are currently no available resources to book.</p>
</div>
);
}
return (
<div className="create-reservation">
<div className="page-header">
<h1>Create New Reservation</h1>
</div>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit} className="reservation-form">
<div className="form-group">
<label htmlFor="resourceId">Resource *</label>
<select
id="resourceId"
value={formData.resourceId}
onChange={(e) => handleInputChange('resourceId', e.target.value)}
required
>
<option value="">Select a resource</option>
{resources.map(resource => (
<option key={resource.id} value={resource.id}>
{resource.name} - {resource.location}
</option>
))}
</select>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="startTime">Start Time *</label>
<input
type="datetime-local"
id="startTime"
value={formData.startTime.toISOString().slice(0, 16)}
onChange={(e) => handleInputChange('startTime', new Date(e.target.value))}
required
/>
</div>
<div className="form-group">
<label htmlFor="endTime">End Time *</label>
<input
type="datetime-local"
id="endTime"
value={formData.endTime.toISOString().slice(0, 16)}
onChange={(e) => handleInputChange('endTime', new Date(e.target.value))}
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="title">Title *</label>
<input
type="text"
id="title"
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Meeting title or purpose"
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Additional details about this reservation"
rows={4}
/>
</div>
<div className="form-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate('/resources')}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={submitting}
>
{submitting ? 'Creating...' : 'Create Reservation'}
</button>
</div>
</form>
</div>
);
};
export default CreateReservation;
+213
View File
@@ -0,0 +1,213 @@
.resource-calendar {
width: 100%;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
gap: 1rem;
flex-wrap: wrap;
}
.header-info h1 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.resource-details {
margin: 0;
color: #7f8c8d;
font-size: 0.9rem;
}
.header-actions {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: flex-end;
}
.week-navigation {
display: flex;
align-items: center;
gap: 1rem;
}
.current-week {
font-weight: 600;
color: #2c3e50;
min-width: 150px;
text-align: center;
}
.calendar-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: auto;
}
.calendar-grid {
display: grid;
grid-template-columns: 100px repeat(7, 1fr);
min-width: 800px;
}
.time-header {
padding: 1rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
border-right: 1px solid #dee2e6;
}
.day-header {
padding: 1rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
border-right: 1px solid #dee2e6;
text-align: center;
}
.day-name {
font-weight: 600;
color: #2c3e50;
font-size: 0.9rem;
}
.day-date {
color: #7f8c8d;
font-size: 0.8rem;
}
.time-label {
padding: 1rem;
background: #f8f9fa;
border-right: 1px solid #dee2e6;
border-bottom: 1px solid #dee2e6;
font-size: 0.8rem;
color: #6c757d;
text-align: center;
}
.time-slot {
min-height: 60px;
padding: 0.5rem;
border-right: 1px solid #dee2e6;
border-bottom: 1px solid #dee2e6;
background: white;
}
.time-slot.today {
background: #fff3cd;
}
.reservation-item {
background: #3498db;
color: white;
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 0.25rem;
font-size: 0.8rem;
}
.reservation-item:last-child {
margin-bottom: 0;
}
.reservation-item.pending {
background: #f39c12;
}
.reservation-item.cancelled {
background: #e74c3c;
}
.reservation-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.reservation-time {
font-size: 0.7rem;
opacity: 0.9;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
text-align: center;
white-space: nowrap;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
.loading,
.error {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
}
.error {
color: #e74c3c;
}
@media (max-width: 1200px) {
.calendar-header {
flex-direction: column;
align-items: stretch;
}
.header-actions {
align-items: stretch;
}
.week-navigation {
justify-content: center;
}
}
@media (max-width: 768px) {
.calendar-grid {
grid-template-columns: 80px repeat(7, 1fr);
min-width: 600px;
}
.day-header,
.time-label,
.time-slot {
padding: 0.5rem;
}
.time-slot {
min-height: 50px;
}
.reservation-item {
font-size: 0.7rem;
padding: 0.25rem;
}
}
+195
View File
@@ -0,0 +1,195 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { format, startOfWeek, addDays, startOfDay, addHours, isSameDay, isWithinInterval } from 'date-fns';
import { api } from '../services/api';
import { Resource, Reservation } from '../types';
import './ResourceCalendar.css';
const ResourceCalendar: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [resource, setResource] = useState<Resource | null>(null);
const [reservations, setReservations] = useState<Reservation[]>([]);
const [currentWeek, setCurrentWeek] = useState(new Date());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (id) {
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, currentWeek]);
const loadData = async () => {
if (!id) {
setError('Invalid resource ID');
return;
}
try {
setLoading(true);
setError(null);
const [resourceData, reservationsData] = await Promise.all([
api.getResource(id),
api.getReservations(id)
]);
if (!resourceData) {
setError('Resource not found');
return;
}
console.log('Calendar - Resource:', resourceData);
console.log('Calendar - Reservations:', reservationsData);
setResource(resourceData);
setReservations(Array.isArray(reservationsData) ? reservationsData : []);
} catch (err) {
console.error('Calendar load error:', err);
setError('Failed to load resource calendar');
} finally {
setLoading(false);
}
};
const weekStart = startOfWeek(currentWeek, { weekStartsOn: 1 }); // Start on Monday
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
// Generate time slots from 8 AM to 8 PM
const timeSlots = Array.from({ length: 13 }, (_, i) => addHours(startOfDay(weekStart), 8 + i));
const getReservationsForSlot = (date: Date, timeSlot: Date): Reservation[] => {
if (!date || !timeSlot) return [];
const slotStart = timeSlot;
const slotEnd = addHours(timeSlot, 1);
const filtered = reservations.filter(reservation => {
if (!reservation?.startTime) return false;
const reservationDate = new Date(reservation.startTime);
if (isNaN(reservationDate.getTime())) return false;
const matches = isSameDay(reservationDate, date) &&
isWithinInterval(reservationDate, { start: slotStart, end: slotEnd });
if (matches) {
console.log('Found reservation for slot:', {
title: reservation.title,
date: reservationDate,
slotStart,
slotEnd,
matches
});
}
return matches;
});
return filtered;
};
const navigateWeek = (direction: 'prev' | 'next') => {
setCurrentWeek(prev => addDays(prev, direction === 'next' ? 7 : -7));
};
const goToToday = () => {
setCurrentWeek(new Date());
};
if (loading) {
return <div className="loading">Loading calendar...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
if (!resource) {
return <div className="error">Resource not found</div>;
}
return (
<div className="resource-calendar">
<div className="calendar-header">
<div className="header-info">
<h1>{resource.name} - Calendar</h1>
<p className="resource-details">
{resource.location} Capacity: {resource.capacity}
</p>
</div>
<div className="header-actions">
<div className="week-navigation">
<button onClick={() => navigateWeek('prev')} className="btn btn-secondary">
Previous
</button>
<button onClick={goToToday} className="btn btn-secondary">
Today
</button>
<span className="current-week">
{format(weekStart, 'MMM d')} - {format(addDays(weekStart, 6), 'MMM d, yyyy')}
</span>
<button onClick={() => navigateWeek('next')} className="btn btn-secondary">
Next
</button>
</div>
<Link to="/reservations/new" className="btn btn-primary">
Book Resource
</Link>
</div>
</div>
<div className="calendar-container">
<div className="calendar-grid">
{/* Time column header */}
<div className="time-header"></div>
{/* Day headers */}
{weekDays.map(day => (
<div key={day.toString()} className="day-header">
<div className="day-name">{format(day, 'EEE')}</div>
<div className="day-date">{format(day, 'd')}</div>
</div>
))}
{/* Time slots */}
{timeSlots.map(timeSlot => (
<React.Fragment key={timeSlot.toString()}>
<div className="time-label">
{format(timeSlot, 'h:mm a')}
</div>
{weekDays.map(day => {
const slotReservations = getReservationsForSlot(day, timeSlot);
const isToday = isSameDay(day, new Date());
return (
<div
key={`${day.toString()}-${timeSlot.toString()}`}
className={`time-slot ${isToday ? 'today' : ''}`}
>
{slotReservations.map(reservation => (
<div
key={reservation.id}
className={`reservation-item ${reservation.status}`}
>
<div className="reservation-title">{reservation.title}</div>
<div className="reservation-time">
{format(new Date(reservation.startTime), 'h:mm a')} -
{format(new Date(reservation.endTime), 'h:mm a')}
</div>
</div>
))}
</div>
);
})}
</React.Fragment>
))}
</div>
</div>
</div>
);
};
export default ResourceCalendar;
+189
View File
@@ -0,0 +1,189 @@
.resources-list {
width: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
gap: 1rem;
}
.page-header h1 {
margin: 0;
color: #2c3e50;
}
.resources-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 2rem;
}
.resource-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.resource-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.resource-image {
height: 200px;
background: #ecf0f1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.resource-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder-image {
font-size: 3rem;
color: #95a5a6;
}
.resource-content {
padding: 1.5rem;
}
.resource-content h3 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
font-size: 1.25rem;
}
.resource-description {
color: #7f8c8d;
margin: 0 0 1rem 0;
line-height: 1.5;
}
.resource-location,
.resource-capacity {
margin: 0.5rem 0;
color: #34495e;
font-size: 0.9rem;
}
.resource-amenities {
margin: 1rem 0;
}
.resource-amenities strong {
color: #2c3e50;
font-size: 0.9rem;
}
.resource-amenities ul {
margin: 0.5rem 0 0 0;
padding-left: 1.5rem;
color: #7f8c8d;
font-size: 0.85rem;
}
.resource-amenities li {
margin: 0.25rem 0;
}
.resource-status {
margin: 1rem 0;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.available {
background-color: #d4edda;
color: #155724;
}
.status-badge.unavailable {
background-color: #f8d7da;
color: #721c24;
}
.resource-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
text-align: center;
flex: 1;
}
.btn:disabled,
.btn.disabled {
opacity: 0.6;
cursor: not-allowed;
text-decoration: none;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #7f8c8d;
}
.loading, .error {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
}
.error {
color: #e74c3c;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.resources-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.resource-actions {
flex-direction: column;
}
}
+113
View File
@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../services/api';
import { Resource } from '../types';
import './ResourcesList.css';
const ResourcesList: React.FC = () => {
const [resources, setResources] = useState<Resource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadResources();
}, []);
const loadResources = async () => {
try {
setLoading(true);
const data = await api.getResources();
setResources(data);
} catch (err) {
setError('Failed to load resources');
} finally {
setLoading(false);
}
};
if (loading) {
return <div className="loading">Loading resources...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
return (
<div className="resources-list">
<div className="page-header">
<h1>Available Resources</h1>
<Link to="/reservations/new" className="btn btn-primary">
New Reservation
</Link>
</div>
<div className="resources-grid">
{resources.map(resource => (
<div key={resource.id} className="resource-card">
<div className="resource-image">
{resource.imageUrl ? (
<img src={resource.imageUrl} alt={resource.name} />
) : (
<div className="placeholder-image">🏢</div>
)}
</div>
<div className="resource-content">
<h3>{resource.name}</h3>
<p className="resource-description">{resource.description}</p>
{resource.location && (
<p className="resource-location">📍 {resource.location}</p>
)}
{resource.capacity && (
<p className="resource-capacity">👥 Capacity: {resource.capacity}</p>
)}
{resource.amenities && resource.amenities.length > 0 && (
<div className="resource-amenities">
<strong>Amenities:</strong>
<ul>
{resource.amenities.map((amenity, index) => (
<li key={index}>{amenity}</li>
))}
</ul>
</div>
)}
<div className="resource-status">
<span className={`status-badge ${resource.isAvailable ? 'available' : 'unavailable'}`}>
{resource.isAvailable ? 'Available' : 'Unavailable'}
</span>
</div>
<div className="resource-actions">
<Link
to={`/resources/${resource.id}/calendar`}
className="btn btn-secondary"
>
View Calendar
</Link>
{resource.isAvailable ? (
<Link
to={`/reservations/new?resourceId=${resource.id}`}
className="btn btn-primary"
>
Book Now
</Link>
) : (
<span className="btn btn-primary disabled">
Book Now
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default ResourcesList;
+361
View File
@@ -0,0 +1,361 @@
.user-dashboard {
width: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
gap: 1rem;
flex-wrap: wrap;
}
.header-content h1 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.header-content p {
margin: 0;
color: #7f8c8d;
}
.header-actions {
display: flex;
gap: 1rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}
.stat-card h3 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
color: #3498db;
}
.stat-card p {
margin: 0;
color: #7f8c8d;
font-weight: 500;
}
.filters-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.filter-tabs {
display: flex;
gap: 0.5rem;
background: white;
padding: 0.25rem;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.filter-tab {
padding: 0.75rem 1.5rem;
border: none;
background: transparent;
color: #7f8c8d;
cursor: pointer;
border-radius: 4px;
font-weight: 500;
transition: all 0.2s;
}
.filter-tab:hover {
background: #f8f9fa;
}
.filter-tab.active {
background: #3498db;
color: white;
}
.date-filter {
display: flex;
align-items: center;
gap: 0.5rem;
background: white;
padding: 0.5rem;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.date-input {
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem;
font-size: 0.9rem;
}
.clear-date-btn {
background: none;
border: none;
color: #7f8c8d;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 1rem;
}
.clear-date-btn:hover {
background: #f8f9fa;
color: #2c3e50;
}
.reservations-section {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.section-header h2 {
margin: 0;
color: #2c3e50;
font-size: 1.25rem;
}
.result-count {
color: #7f8c8d;
font-size: 0.9rem;
}
.reservations-list {
padding: 1rem;
}
.reservation-card {
border: 1px solid #dee2e6;
border-radius: 6px;
margin-bottom: 1rem;
padding: 1.5rem;
background: white;
transition: box-shadow 0.2s;
}
.reservation-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.reservation-card:last-child {
margin-bottom: 0;
}
.reservation-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
gap: 1rem;
}
.reservation-info h3 {
margin: 0 0 0.25rem 0;
color: #2c3e50;
font-size: 1.2rem;
}
.resource-name {
margin: 0;
color: #7f8c8d;
font-size: 0.9rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
color: white;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
.reservation-details {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
}
.detail-group {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
.detail-item {
display: flex;
align-items: center;
gap: 0.5rem;
color: #555;
font-size: 0.9rem;
}
.detail-icon {
font-size: 1rem;
}
.detail-item.description {
width: 100%;
align-items: flex-start;
}
.reservation-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid #eee;
gap: 1rem;
}
.pending-note {
color: #f39c12;
font-size: 0.9rem;
font-weight: 500;
}
.no-reservations {
text-align: center;
padding: 3rem 2rem;
color: #7f8c8d;
}
.no-reservations-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.no-reservations h3 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.no-reservations p {
margin: 0 0 1.5rem 0;
font-size: 0.95rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
font-weight: 500;
text-decoration: none;
text-align: center;
display: inline-block;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
.loading,
.error {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
}
.error {
color: #e74c3c;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.filters-section {
flex-direction: column;
align-items: stretch;
}
.filter-tabs {
justify-content: center;
}
.date-filter {
justify-content: center;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.reservation-header {
flex-direction: column;
align-items: flex-start;
}
.detail-group {
flex-direction: column;
gap: 0.75rem;
}
.reservation-actions {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.stats-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
}
+282
View File
@@ -0,0 +1,282 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { format, startOfDay, isAfter, isBefore } from 'date-fns';
import { api } from '../services/api';
import { Reservation } from '../types';
import './UserDashboard.css';
const UserDashboard: React.FC = () => {
const [reservations, setReservations] = useState<Reservation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
useEffect(() => {
loadReservations();
}, []);
const loadReservations = async () => {
try {
setLoading(true);
const data = await api.getReservations();
// Sort by start date, upcoming first
const sortedData = data.sort((a, b) =>
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
);
setReservations(sortedData);
} catch (err) {
setError('Failed to load reservations');
} finally {
setLoading(false);
}
};
const filterReservations = (reservations: Reservation[]) => {
const now = new Date();
const today = startOfDay(now);
switch (filter) {
case 'upcoming':
return reservations.filter(r =>
isAfter(new Date(r.startTime), today) ||
isSameDay(new Date(r.startTime), today)
);
case 'past':
return reservations.filter(r =>
isBefore(new Date(r.startTime), today)
);
default:
return reservations;
}
};
const filterByDate = (reservations: Reservation[]) => {
if (!selectedDate) return reservations;
return reservations.filter(r =>
isSameDay(new Date(r.startTime), selectedDate)
);
};
const getFilteredReservations = () => {
let filtered = filterReservations(reservations);
filtered = filterByDate(filtered);
return filtered;
};
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed':
return '#27ae60';
case 'pending':
return '#f39c12';
case 'cancelled':
return '#e74c3c';
default:
return '#95a5a6';
}
};
const isSameDay = (date1: Date, date2: Date) => {
return format(date1, 'yyyy-MM-dd') === format(date2, 'yyyy-MM-dd');
};
const clearDateFilter = () => {
setSelectedDate(null);
};
const upcomingReservations = filterReservations(reservations).filter(r =>
isAfter(new Date(r.startTime), new Date()) ||
isSameDay(new Date(r.startTime), new Date())
);
if (loading) {
return <div className="loading">Loading reservations...</div>;
}
const filteredReservations = getFilteredReservations();
return (
<div className="user-dashboard">
<div className="page-header">
<div className="header-content">
<h1>My Reservations</h1>
<p>View and manage all your resource bookings</p>
</div>
<div className="header-actions">
<Link to="/reservations/new" className="btn btn-primary">
New Reservation
</Link>
</div>
</div>
{error && <div className="error">{error}</div>}
{/* Quick Stats */}
<div className="stats-grid">
<div className="stat-card">
<h3>{upcomingReservations.length}</h3>
<p>Upcoming Reservations</p>
</div>
<div className="stat-card">
<h3>{upcomingReservations.filter(r => r.status === 'confirmed').length}</h3>
<p>Confirmed Bookings</p>
</div>
<div className="stat-card">
<h3>{upcomingReservations.filter(r => r.status === 'pending').length}</h3>
<p>Pending Approval</p>
</div>
</div>
{/* Filters */}
<div className="filters-section">
<div className="filter-tabs">
<button
className={`filter-tab ${filter === 'upcoming' ? 'active' : ''}`}
onClick={() => setFilter('upcoming')}
>
Upcoming
</button>
<button
className={`filter-tab ${filter === 'past' ? 'active' : ''}`}
onClick={() => setFilter('past')}
>
Past
</button>
<button
className={`filter-tab ${filter === 'all' ? 'active' : ''}`}
onClick={() => setFilter('all')}
>
All
</button>
</div>
<div className="date-filter">
<input
type="date"
value={selectedDate ? format(selectedDate, 'yyyy-MM-dd') : ''}
onChange={(e) => setSelectedDate(e.target.value ? new Date(e.target.value) : null)}
className="date-input"
/>
{selectedDate && (
<button onClick={clearDateFilter} className="clear-date-btn">
</button>
)}
</div>
</div>
{/* Reservations List */}
<div className="reservations-section">
<div className="section-header">
<h2>
{filter === 'upcoming' && 'Upcoming Reservations'}
{filter === 'past' && 'Past Reservations'}
{filter === 'all' && 'All Reservations'}
{selectedDate && ` - ${format(selectedDate, 'MMM d, yyyy')}`}
</h2>
<span className="result-count">
{filteredReservations.length} {filteredReservations.length === 1 ? 'reservation' : 'reservations'}
</span>
</div>
{filteredReservations.length === 0 ? (
<div className="no-reservations">
<div className="no-reservations-icon">📅</div>
<h3>No reservations found</h3>
<p>
{selectedDate
? `No reservations found for ${format(selectedDate, 'MMMM d, yyyy')}`
: filter === 'upcoming'
? 'You have no upcoming reservations'
: filter === 'past'
? 'You have no past reservations'
: 'No reservations found'
}
</p>
{!selectedDate && filter === 'upcoming' && (
<Link to="/reservations/new" className="btn btn-primary">
Create Your First Reservation
</Link>
)}
</div>
) : (
<div className="reservations-list">
{filteredReservations.map(reservation => (
<div key={reservation.id} className="reservation-card">
<div className="reservation-header">
<div className="reservation-info">
<h3>{reservation.title}</h3>
<p className="resource-name">{reservation.resource.name}</p>
</div>
<div className="reservation-status">
<span
className="status-badge"
style={{ backgroundColor: getStatusColor(reservation.status) }}
>
{reservation.status}
</span>
</div>
</div>
<div className="reservation-details">
<div className="detail-group">
<div className="detail-item">
<span className="detail-icon">📅</span>
<span>{format(new Date(reservation.startTime), 'EEEE, MMMM d, yyyy')}</span>
</div>
<div className="detail-item">
<span className="detail-icon">🕐</span>
<span>
{format(new Date(reservation.startTime), 'h:mm a')} -
{format(new Date(reservation.endTime), 'h:mm a')}
</span>
</div>
</div>
<div className="detail-group">
<div className="detail-item">
<span className="detail-icon">📍</span>
<span>{reservation.resource.location}</span>
</div>
{reservation.resource.capacity && (
<div className="detail-item">
<span className="detail-icon">👥</span>
<span>Capacity: {reservation.resource.capacity}</span>
</div>
)}
</div>
{reservation.description && (
<div className="detail-group">
<div className="detail-item description">
<span className="detail-icon">📝</span>
<span>{reservation.description}</span>
</div>
</div>
)}
</div>
<div className="reservation-actions">
<Link
to={`/resources/${reservation.resourceId}/calendar`}
className="btn btn-secondary"
>
View Calendar
</Link>
{reservation.status === 'pending' && (
<div className="pending-note">
Awaiting approval
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default UserDashboard;
+131
View File
@@ -0,0 +1,131 @@
import { Resource, Reservation, CreateReservationRequest } from '../types';
// Mock data for development - will be replaced with real API calls
const mockResources: Resource[] = [
{
id: '1',
name: 'Conference Room A',
description: 'Large conference room with projector and whiteboard',
location: 'Building 1, Floor 2',
capacity: 20,
imageUrl: '/images/conference-room.jpg',
amenities: ['Projector', 'Whiteboard', 'Video Conference'],
isAvailable: true,
},
{
id: '2',
name: 'Meeting Room B',
description: 'Small meeting room for team discussions',
location: 'Building 1, Floor 1',
capacity: 6,
amenities: ['TV', 'Whiteboard'],
isAvailable: true,
},
{
id: '3',
name: 'Training Room',
description: 'Spacious training room with multiple screens',
location: 'Building 2, Floor 3',
capacity: 30,
amenities: ['Projector', 'Sound System', 'Recording Equipment'],
isAvailable: false,
},
];
const mockReservations: Reservation[] = [
{
id: '1',
resourceId: '1',
resource: mockResources[0],
userId: 'user1',
startTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}T10:00:00`),
endTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}T12:00:00`),
title: 'Team Meeting',
description: 'Weekly team sync',
status: 'confirmed',
createdAt: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate() - 2).padStart(2, '0')}T09:00:00`),
},
{
id: '2',
resourceId: '2',
resource: mockResources[1],
userId: 'user2',
startTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}T14:00:00`),
endTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}T15:30:00`),
title: 'Client Presentation',
status: 'pending',
createdAt: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate() - 1).padStart(2, '0')}T11:00:00`),
},
{
id: '3',
resourceId: '1',
resource: mockResources[0],
userId: 'user3',
startTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate() + 1).padStart(2, '0')}T09:00:00`),
endTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate() + 1).padStart(2, '0')}T11:00:00`),
title: 'Project Planning',
description: 'Q1 planning session',
status: 'pending',
createdAt: new Date(),
},
];
export const api = {
// Resources
getResources: async (): Promise<Resource[]> => {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500));
return mockResources;
},
getResource: async (id: string): Promise<Resource | null> => {
await new Promise(resolve => setTimeout(resolve, 300));
return mockResources.find(r => r.id === id) || null;
},
// Reservations
getReservations: async (resourceId?: string): Promise<Reservation[]> => {
await new Promise(resolve => setTimeout(resolve, 500));
if (resourceId) {
return mockReservations.filter(r => r.resourceId === resourceId);
}
return mockReservations;
},
createReservation: async (data: CreateReservationRequest): Promise<Reservation> => {
await new Promise(resolve => setTimeout(resolve, 800));
const resource = mockResources.find(r => r.id === data.resourceId);
if (!resource) {
throw new Error('Resource not found');
}
const newReservation: Reservation = {
id: Date.now().toString(),
resourceId: data.resourceId,
resource,
userId: 'current-user', // Will come from authentication
startTime: data.startTime,
endTime: data.endTime,
title: data.title,
description: data.description,
status: 'pending',
createdAt: new Date(),
};
mockReservations.push(newReservation);
console.log('New reservation created:', newReservation);
console.log('All reservations now:', mockReservations);
return newReservation;
},
updateReservationStatus: async (id: string, status: 'confirmed' | 'cancelled'): Promise<Reservation> => {
await new Promise(resolve => setTimeout(resolve, 500));
const reservation = mockReservations.find(r => r.id === id);
if (!reservation) {
throw new Error('Reservation not found');
}
reservation.status = status;
return reservation;
},
};
+133
View File
@@ -0,0 +1,133 @@
import { Resource, Reservation, CreateReservationRequest } from '../types';
export interface LibreBookingAPIConfig {
baseURL: string;
apiKey?: string;
timeout?: number;
}
class LibreBookingAPIClient {
private config: LibreBookingAPIConfig;
private baseUrl: string;
constructor(config: LibreBookingAPIConfig) {
this.config = config;
this.baseUrl = config.baseURL.replace(/\/$/, ''); // Remove trailing slash
}
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
};
if (this.config.apiKey) {
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
}
try {
const response = await fetch(url, {
...options,
headers,
signal: AbortSignal.timeout(this.config.timeout || 10000),
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
return response.text() as unknown as T;
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`Network error: ${error.message}`);
}
throw error;
}
}
// Resources
async getResources(): Promise<Resource[]> {
return this.makeRequest<Resource[]>('/api/resources');
}
async getResource(id: string): Promise<Resource> {
return this.makeRequest<Resource>(`/api/resources/${id}`);
}
// Reservations
async getReservations(resourceId?: string): Promise<Reservation[]> {
const params = resourceId ? `?resourceId=${resourceId}` : '';
return this.makeRequest<Reservation[]>(`/api/reservations${params}`);
}
async getReservation(id: string): Promise<Reservation> {
return this.makeRequest<Reservation>(`/api/reservations/${id}`);
}
async createReservation(data: CreateReservationRequest): Promise<Reservation> {
return this.makeRequest<Reservation>('/api/reservations', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateReservationStatus(
id: string,
status: 'confirmed' | 'cancelled'
): Promise<Reservation> {
return this.makeRequest<Reservation>(`/api/reservations/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
}
async cancelReservation(id: string): Promise<Reservation> {
return this.makeRequest<Reservation>(`/api/reservations/${id}/cancel`, {
method: 'POST',
});
}
// Users (if needed for admin functions)
async getUsers(): Promise<any[]> {
return this.makeRequest<any[]>('/api/users');
}
async getUserReservations(userId: string): Promise<Reservation[]> {
return this.makeRequest<Reservation[]>(`/api/users/${userId}/reservations`);
}
}
// Factory function to create API client with environment variables
export function createLibreBookingClient(
config?: Partial<LibreBookingAPIConfig>
): LibreBookingAPIClient {
const defaultConfig: LibreBookingAPIConfig = {
baseURL: process.env.REACT_APP_LIBREBOOKING_API_URL || 'http://localhost:8080',
apiKey: process.env.REACT_APP_LIBREBOOKING_API_KEY,
timeout: 10000,
};
const finalConfig = { ...defaultConfig, ...config };
return new LibreBookingAPIClient(finalConfig);
}
// Example of how to integrate with the existing mock API
export function integrateWithMockAPI(mockAPI: any) {
return {
getResources: mockAPI.getResources,
getResource: mockAPI.getResource,
getReservations: mockAPI.getReservations,
createReservation: mockAPI.createReservation,
updateReservationStatus: mockAPI.updateReservationStatus,
};
}
+38
View File
@@ -0,0 +1,38 @@
export interface Resource {
id: string;
name: string;
description?: string;
location?: string;
capacity?: number;
imageUrl?: string;
amenities?: string[];
isAvailable: boolean;
}
export interface Reservation {
id: string;
resourceId: string;
resource: Resource;
userId: string;
startTime: Date;
endTime: Date;
title: string;
description?: string;
status: 'pending' | 'confirmed' | 'cancelled';
createdAt: Date;
}
export interface User {
id: string;
name: string;
email: string;
role: 'user' | 'admin';
}
export interface CreateReservationRequest {
resourceId: string;
startTime: Date;
endTime: Date;
title: string;
description?: string;
}