working api connection

This commit is contained in:
Joshua Schmucker 2026-02-03 11:51:59 +01:00
parent bf4136b8dc
commit af49dea1d6
20 changed files with 1319 additions and 2111 deletions

7
.env Normal file
View File

@ -0,0 +1,7 @@
# Environment variables for LibreBooking API integration
REACT_APP_LIBREBOOKING_API_URL=http://localhost:8080/Web
# Optional: API key if authentication is required
# REACT_APP_LIBREBOOKING_API_KEY=your-api-key-here
# LibreBooking credentials for authentication
REACT_APP_LIBREBOOKING_USERNAME=admin
REACT_APP_LIBREBOOKING_PASSWORD=password

56
public/test.html Normal file
View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LibreBooking UI - Test</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.status {
background: #e8f5e8;
border: 1px solid #d4edda;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
.success { background: #d4edda; border-color: #c3e6cb; color: #155724; }
.error { background: #f8d7da; border-color: #f5c6cb; color: #721c24; }
</style>
</head>
<body>
<div class="container">
<h1>LibreBooking UI</h1>
<div class="status success">
<strong>✅ React App Structure Fixed!</strong>
</div>
<p>The application framework has been debugged and is working properly.</p>
<h3>Integration Status:</h3>
<ul>
<li>✅ React development server compiled successfully</li>
<li>✅ TypeScript errors resolved</li>
<li>✅ API client structure implemented</li>
<li>✅ Mock data properly formatted</li>
<li>✅ Date parsing issues fixed</li>
<li>⚠️ LibreBooking API connection - needs backend configuration</li>
</ul>
<h3>Next Steps:</h3>
<p>1. Configure LibreBooking API to enable JSON responses</p>
<p>2. Verify authentication credentials</p>
<p>3. Test full API integration</p>
</div>
</body>
</html>

12
src/App.minimal.tsx Normal file
View File

@ -0,0 +1,12 @@
import React from 'react';
function App() {
return (
<div>
<h1>LibreBooking UI</h1>
<p>Testing basic React functionality</p>
</div>
);
}
export default App;

View File

@ -13,6 +13,8 @@
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: relative;
flex-wrap: wrap;
gap: 1rem;
}
.nav-brand {
@ -33,6 +35,40 @@
font-weight: 600;
}
/* API Status Indicator */
.api-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #ecf0f1;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #95a5a6;
}
.status-checking {
background-color: #f39c12;
animation: pulse 1.5s infinite;
}
.status-available {
background-color: #27ae60;
}
.status-unavailable {
background-color: #e74c3c;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Desktop Navigation */
.nav-links-desktop {
display: flex;

22
src/hooks/useAPIStatus.ts Normal file
View File

@ -0,0 +1,22 @@
import { useState, useEffect } from 'react';
export type APIStatus = 'checking' | 'available' | 'unavailable';
export function useAPIStatus(): APIStatus {
const [status, setStatus] = useState<APIStatus>('checking');
useEffect(() => {
async function checkAPI() {
try {
const response = await fetch(`${process.env.REACT_APP_LIBREBOOKING_API_URL}/Services/index.php/Resources/Status`);
setStatus(response.ok ? 'available' : 'unavailable');
} catch (error) {
setStatus('unavailable');
}
}
checkAPI();
}, []);
return status;
}

View File

@ -2,7 +2,6 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
@ -12,8 +11,3 @@ root.render(
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@ -1,217 +0,0 @@
.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;
}
}

View File

@ -1,224 +1,90 @@
import React, { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { api } from '../services/api';
import { useNavigate, Link } from 'react-router-dom';
import { Reservation } from '../types';
import './AdminDashboard.css';
import { SimpleLibreBookingClient } from '../services/librebooking-api';
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(() => {
const apiClient = new SimpleLibreBookingClient();
const loadReservations = async () => {
try {
const reservationsData = await apiClient.getReservations();
setReservations(reservationsData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load reservations');
} finally {
setLoading(false);
}
};
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>Loading...</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] }}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h2>Admin Dashboard</h2>
<Link
to="/resources"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: '#3498db',
color: 'white',
textDecoration: 'none',
borderRadius: '4px',
fontWeight: '500'
}}
>
{reservation.status}
</span>
Manage Resources
</Link>
</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}
{/* Quick Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd' }}>
<h3>Total Reservations</h3>
<div style={{ fontSize: '2rem', color: '#2c3e50', marginBottom: '0.5rem' }}>
{reservations.length}
</div>
</div>
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd' }}>
<h3>Pending Approvals</h3>
<div style={{ fontSize: '2rem', color: '#f39c12', marginBottom: '0.5rem' }}>
{pendingReservations.length}
</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>
)}
{/* Pending Approvals */}
{pendingReservations.length > 0 && (
<div style={{ marginBottom: '2rem' }}>
<h3>Pending Approval ({pendingReservations.length})</h3>
<div style={{ backgroundColor: '#fff3cd', padding: '1rem', borderRadius: '8px', border: '1px solid #ffc107', marginBottom: '1rem' }}>
You have {pendingReservations.length} pending reservations that need approval
</div>
</div>
)}
{/* Recent Activity */}
<div style={{ marginBottom: '2rem' }}>
<h3>Recent Activity</h3>
<div style={{ fontSize: '1rem', color: '#555', marginBottom: '0.5rem' }}>
No recent activity
</div>
</div>
</div>
);
};

View File

@ -1,142 +0,0 @@
.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;
}
}

View File

@ -1,182 +1,333 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { api } from '../services/api';
import { useNavigate, Link } from 'react-router-dom';
import { Resource, CreateReservationRequest } from '../types';
import './CreateReservation.css';
import { SimpleLibreBookingClient } from '../services/librebooking-api';
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(),
const [formData, setFormData] = useState({
resourceId: '',
title: '',
description: '',
date: '',
startTime: '',
endTime: ''
});
const [resources, setResources] = useState<Resource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const apiClient = new SimpleLibreBookingClient();
useEffect(() => {
const loadResources = async () => {
try {
const resourcesData = await apiClient.getResources();
setResources(resourcesData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load resources');
} finally {
setLoading(false);
}
};
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 handleInputChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = event.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validateForm = (): string | null => {
if (!formData.resourceId) return 'Please select a resource';
if (!formData.title.trim()) return 'Please enter a title';
if (!formData.date) return 'Please select a date';
if (!formData.startTime) return 'Please select a start time';
if (!formData.endTime) return 'Please select an end time';
if (formData.startTime >= formData.endTime) return 'End time must be after start time';
return null;
};
const handleSubmit = async (event: React.FormEvent) => {
event.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');
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
setSubmitting(true);
setError(null);
try {
setSubmitting(true);
setError(null);
const requestData: CreateReservationRequest = {
resourceId: formData.resourceId,
startDateTime: `${formData.date}T${formData.startTime}`,
endDateTime: `${formData.date}T${formData.endTime}`,
title: formData.title.trim(),
description: formData.description.trim() || '',
userId: apiClient.getUserId() || undefined,
allowParticipation: true,
termsAccepted: true
};
console.log('Creating reservation with data:', requestData);
const reservation = await apiClient.createReservation(requestData);
await api.createReservation(formData);
// Navigate to resources list on success
navigate('/resources', {
state: { message: 'Reservation created successfully!' }
navigate(`/resources/${formData.resourceId}/calendar`, {
state: { newReservation: reservation }
});
} catch (err) {
setError('Failed to create reservation');
setError(err instanceof Error ? err.message : 'Failed to create reservation');
} finally {
setSubmitting(false);
}
};
const handleInputChange = (field: keyof CreateReservationRequest, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const selectedResource = resources.find(r => r.id === formData.resourceId);
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>Loading...</div>;
}
return (
<div className="create-reservation">
<div className="page-header">
<h1>Create New Reservation</h1>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h2>Create Reservation</h2>
<Link
to="/resources"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: '#3498db',
color: 'white',
textDecoration: 'none',
borderRadius: '4px',
fontWeight: '500'
}}
>
Cancel
</Link>
</div>
{error && <div className="error">{error}</div>}
{error && (
<div style={{
backgroundColor: '#f8d7da',
color: '#721c24',
padding: '1rem',
borderRadius: '4px',
marginBottom: '1.5rem',
border: '1px solid #f5c6cb'
}}>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="reservation-form">
<div className="form-group">
<label htmlFor="resourceId">Resource *</label>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1.5rem' }}>
<h3>Create New Reservation</h3>
</div>
{/* Resource Selection */}
<div style={{ marginBottom: '1.5rem' }}>
<label htmlFor="resourceId" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
Resource *
</label>
<select
id="resourceId"
name="resourceId"
value={formData.resourceId}
onChange={(e) => handleInputChange('resourceId', e.target.value)}
onChange={handleInputChange}
required
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem'
}}
>
<option value="">Select a resource</option>
<option value="">Select a resource...</option>
{resources.map(resource => (
<option key={resource.id} value={resource.id}>
{resource.name} - {resource.location}
{resource.name} ({resource.capacity ? `Capacity: ${resource.capacity}` : 'No capacity limit'})
</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
/>
{/* Resource Details */}
{selectedResource && (
<div style={{
backgroundColor: '#f8f9fa',
padding: '1.5rem',
borderRadius: '8px',
border: '1px solid #e9ecef',
marginBottom: '1.5rem'
}}>
<h4 style={{ margin: '0 0 0.5rem 0', color: '#2c3e50' }}>
{selectedResource.name}
</h4>
{selectedResource.description && (
<p style={{ color: '#666', marginBottom: '0.5rem' }}>
{selectedResource.description}
</p>
)}
{selectedResource.location && (
<div style={{ fontSize: '0.875rem', color: '#555', marginBottom: '0.5rem' }}>
<strong>Location:</strong> {selectedResource.location}
</div>
)}
{selectedResource.capacity && (
<div style={{ fontSize: '0.875rem', color: '#555', marginBottom: '0.5rem' }}>
<strong>Capacity:</strong> {selectedResource.capacity} people
</div>
)}
{selectedResource.amenities && selectedResource.amenities.length > 0 && (
<div style={{ fontSize: '0.875rem', color: '#555', marginBottom: '0.5rem' }}>
<strong>Amenities:</strong> {selectedResource.amenities.join(', ')}
</div>
)}
</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>
{/* Title */}
<div style={{ marginBottom: '1.5rem' }}>
<label htmlFor="title" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
Title *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Meeting title or purpose"
onChange={handleInputChange}
required
placeholder="e.g., Team Meeting, Client Presentation"
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem'
}}
/>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
{/* Description */}
<div style={{ marginBottom: '1.5rem' }}>
<label htmlFor="description" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
Description
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Additional details about this reservation"
rows={4}
onChange={handleInputChange}
rows={3}
placeholder="Optional: Add any additional details about this reservation"
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
resize: 'vertical'
}}
/>
</div>
<div className="form-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate('/resources')}
disabled={submitting}
>
Cancel
</button>
{/* Date Selection */}
<div style={{ marginBottom: '1.5rem' }}>
<label htmlFor="date" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
Date *
</label>
<input
type="date"
id="date"
name="date"
value={formData.date}
onChange={handleInputChange}
required
min={new Date().toISOString().split('T')[0]}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem'
}}
/>
</div>
{/* Time Selection */}
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', gap: '1rem' }}>
<div style={{ flex: 1 }}>
<label htmlFor="startTime" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
Start Time *
</label>
<input
type="time"
id="startTime"
name="startTime"
value={formData.startTime}
onChange={handleInputChange}
required
min="08:00"
max="20:00"
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem'
}}
/>
</div>
<div style={{ flex: 1 }}>
<label htmlFor="endTime" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
End Time *
</label>
<input
type="time"
id="endTime"
name="endTime"
value={formData.endTime}
onChange={handleInputChange}
required
min="08:00"
max="20:00"
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem'
}}
/>
</div>
</div>
</div>
{/* Form Actions */}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button
type="submit"
className="btn btn-primary"
disabled={submitting}
style={{
padding: '0.75rem 1.5rem',
backgroundColor: submitting ? '#95a5a6' : '#27ae60',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: submitting ? 'not-allowed' : 'pointer',
fontSize: '1rem'
}}
>
{submitting ? 'Creating...' : 'Create Reservation'}
</button>

View File

@ -1,213 +0,0 @@
.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;
}
}

View File

@ -1,9 +1,8 @@
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';
import { SimpleLibreBookingClient } from '../services/librebooking-api';
import { format, startOfWeek, addDays, isSameDay, isToday, addHours, setMinutes, setHours } from 'date-fns';
const ResourceCalendar: React.FC = () => {
const { id } = useParams<{ id: string }>();
@ -14,178 +13,248 @@ const ResourceCalendar: React.FC = () => {
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;
}
if (!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;
const api = new SimpleLibreBookingClient();
const loadData = async () => {
try {
setLoading(true);
const [resourceData, reservationsData] = await Promise.all([
api.getResource(id),
api.getReservations(id)
]);
setResource(resourceData);
setReservations(reservationsData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
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
loadData();
}, [id]);
const weekStart = startOfWeek(currentWeek, { weekStartsOn: 1 });
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 timeSlots = Array.from({ length: 13 }, (_, i) => {
const hour = i + 8; // Start at 8 AM
return setMinutes(setHours(new Date(), hour), 0);
});
const getReservationsForSlot = (date: Date, timeSlot: Date): Reservation[] => {
if (!date || !timeSlot) return [];
const getReservationForSlot = (date: Date, timeSlot: Date): Reservation | null => {
const slotStart = setHours(setMinutes(new Date(date), timeSlot.getHours()), timeSlot.getMinutes());
const slotEnd = addHours(slotStart, 1);
const slotStart = timeSlot;
const slotEnd = addHours(timeSlot, 1);
const filtered = reservations.filter(reservation => {
if (!reservation?.startTime) return false;
return reservations.find(reservation => {
const reservationStart = new Date(reservation.startTime);
const reservationEnd = new Date(reservation.endTime);
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;
return (
(reservationStart < slotEnd && reservationEnd > slotStart) ||
(isSameDay(reservationStart, date) &&
reservationStart.getHours() === slotStart.getHours())
);
}) || null;
};
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>;
return (
<div>
<h2>Resource Calendar</h2>
<div>Loading calendar...</div>
</div>
);
}
if (error) {
return <div className="error">{error}</div>;
return (
<div>
<h2>Resource Calendar</h2>
<div style={{ color: '#e74c3c' }}>Error: {error}</div>
</div>
);
}
if (!resource) {
return <div className="error">Resource not found</div>;
return (
<div>
<h2>Resource Calendar</h2>
<div>Resource not found.</div>
</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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<div>
<h2>{resource.name} - Calendar</h2>
{resource.description && (
<p style={{ color: '#666', margin: '0.5rem 0' }}>{resource.description}</p>
)}
</div>
<Link
to={`/reservations/new?resourceId=${resource.id}`}
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: '#27ae60',
color: 'white',
textDecoration: 'none',
borderRadius: '4px',
fontWeight: '500'
}}
>
Book This Resource
</Link>
</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>
{/* Week Navigation */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', padding: '1rem', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
<button
onClick={() => navigateWeek('prev')}
style={{
padding: '0.5rem 1rem',
backgroundColor: '#3498db',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Previous Week
</button>
<h3 style={{ margin: 0, color: '#2c3e50' }}>
{format(weekStart, 'MMM d')} - {format(addDays(weekStart, 6), 'MMM d, yyyy')}
</h3>
<button
onClick={() => navigateWeek('next')}
style={{
padding: '0.5rem 1rem',
backgroundColor: '#3498db',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Next Week
</button>
</div>
{/* Calendar Grid */}
<div style={{
border: '1px solid #ddd',
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'white'
}}>
{/* Header Row */}
<div style={{ display: 'grid', gridTemplateColumns: '100px repeat(7, 1fr)', backgroundColor: '#2c3e50', color: 'white' }}>
<div style={{ padding: '1rem', textAlign: 'center', fontWeight: 'bold', borderRight: '1px solid #ddd' }}>
Time
</div>
{weekDays.map((day, index) => (
<div
key={index}
style={{
padding: '1rem',
textAlign: 'center',
fontWeight: 'bold',
borderRight: index < 6 ? '1px solid #ddd' : 'none',
backgroundColor: isToday(day) ? '#34495e' : 'transparent'
}}
>
<div>{format(day, 'EEEE')}</div>
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>
{format(day, 'MMM d')}
</div>
</div>
))}
</div>
{/* Time slots */}
{timeSlots.map(timeSlot => (
<React.Fragment key={timeSlot.toString()}>
<div className="time-label">
{format(timeSlot, 'h:mm a')}
</div>
{/* Time Slots */}
{timeSlots.map((timeSlot, timeIndex) => (
<div key={timeIndex} style={{ display: 'grid', gridTemplateColumns: '100px repeat(7, 1fr)' }}>
{/* Time Column */}
<div style={{
padding: '0.75rem',
textAlign: 'center',
borderRight: '1px solid #ddd',
borderBottom: '1px solid #ddd',
backgroundColor: '#f8f9fa',
fontSize: '0.875rem'
}}>
{format(timeSlot, 'h a')}
</div>
{/* Day Columns */}
{weekDays.map((day, dayIndex) => {
const reservation = getReservationForSlot(day, timeSlot);
{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>
return (
<div
key={dayIndex}
style={{
padding: '0.25rem',
borderRight: dayIndex < 6 ? '1px solid #ddd' : 'none',
borderBottom: '1px solid #ddd',
backgroundColor: reservation
? reservation.status === 'confirmed' ? '#d4edda'
: reservation.status === 'cancelled' ? '#f8d7da'
: '#fff3cd'
: isToday(day) ? '#f8f9fa' : 'white',
fontSize: '0.75rem',
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{reservation && (
<div style={{
textAlign: 'center',
color: reservation.status === 'cancelled' ? '#721c24' : '#333',
fontWeight: '500',
padding: '0.25rem'
}}>
<div style={{ fontWeight: 'bold' }}>{reservation.title}</div>
<div style={{ fontSize: '0.625rem', opacity: 0.8 }}>
{format(new Date(reservation.startTime), 'h:mm')} - {format(new Date(reservation.endTime), 'h:mm')}
</div>
))}
</div>
);
})}
</React.Fragment>
))}
</div>
)}
</div>
);
})}
</div>
))}
</div>
{/* Legend */}
<div style={{ marginTop: '1.5rem', padding: '1rem', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
<h4 style={{ margin: '0 0 0.5rem 0' }}>Legend</h4>
<div style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '16px', height: '16px', backgroundColor: '#d4edda', border: '1px solid #c3e6cb' }}></div>
<span>Confirmed</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '16px', height: '16px', backgroundColor: '#fff3cd', border: '1px solid #ffeaa7' }}></div>
<span>Pending</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ width: '16px', height: '16px', backgroundColor: '#f8d7da', border: '1px solid #f5c6cb' }}></div>
<span>Cancelled</span>
</div>
</div>
</div>
</div>

View File

@ -1,189 +0,0 @@
.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;
}
}

View File

@ -1,8 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../services/api';
import { Resource } from '../types';
import './ResourcesList.css';
import { SimpleLibreBookingClient } from '../services/librebooking-api';
const ResourcesList: React.FC = () => {
const [resources, setResources] = useState<Resource[]>([]);
@ -10,102 +9,174 @@ const ResourcesList: React.FC = () => {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const api = new SimpleLibreBookingClient();
const loadResources = async () => {
try {
setLoading(true);
const resourcesData = await api.getResources();
console.log('Resources loaded:', resourcesData);
console.log('Resources count:', resourcesData.length);
setResources(resourcesData);
} catch (err) {
console.error('Error loading resources:', err);
setError(err instanceof Error ? err.message : 'Failed to load resources');
} finally {
setLoading(false);
}
};
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>;
return (
<div>
<h2>Resources</h2>
<div>Loading resources...</div>
</div>
);
}
if (error) {
return <div className="error">{error}</div>;
return (
<div>
<h2>Resources</h2>
<div style={{ color: '#e74c3c' }}>Error: {error}</div>
</div>
);
}
return (
<div className="resources-list">
<div className="page-header">
<h1>Available Resources</h1>
<Link to="/reservations/new" className="btn btn-primary">
New Reservation
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h2>Available Resources</h2>
<Link
to="/reservations/new"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: '#3498db',
color: 'white',
textDecoration: 'none',
borderRadius: '4px',
fontWeight: '500'
}}
>
New Booking
</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'}`}>
{resources.length === 0 ? (
<div style={{ textAlign: 'center', padding: '2rem', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
<p>No resources available at the moment.</p>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1.5rem' }}>
{resources.map((resource) => (
<div
key={resource.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1.5rem',
backgroundColor: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
<h3 style={{ margin: 0, color: '#2c3e50' }}>{resource.name}</h3>
<span
style={{
padding: '0.25rem 0.75rem',
borderRadius: '20px',
fontSize: '0.875rem',
fontWeight: '500',
backgroundColor: resource.isAvailable ? '#d4edda' : '#f8d7da',
color: resource.isAvailable ? '#155724' : '#721c24'
}}
>
{resource.isAvailable ? 'Available' : 'Unavailable'}
</span>
</div>
<div className="resource-actions">
{resource.description && (
<p style={{ color: '#666', marginBottom: '1rem', lineHeight: '1.5' }}>
{resource.description}
</p>
)}
{resource.location && (
<div style={{ marginBottom: '0.5rem', color: '#555' }}>
<strong>Location:</strong> {resource.location}
</div>
)}
{resource.capacity && (
<div style={{ marginBottom: '0.5rem', color: '#555' }}>
<strong>Capacity:</strong> {resource.capacity} people
</div>
)}
{resource.amenities && resource.amenities.length > 0 && (
<div style={{ marginBottom: '1rem' }}>
<strong>Amenities:</strong>
<div style={{ marginTop: '0.5rem', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{resource.amenities.map((amenity, index) => (
<span
key={index}
style={{
backgroundColor: '#e9ecef',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
fontSize: '0.875rem'
}}
>
{amenity}
</span>
))}
</div>
</div>
)}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
<Link
to={`/resources/${resource.id}/calendar`}
className="btn btn-secondary"
style={{
flex: 1,
display: 'inline-block',
padding: '0.5rem 1rem',
backgroundColor: '#3498db',
color: 'white',
textDecoration: 'none',
borderRadius: '4px',
textAlign: 'center',
fontSize: '0.875rem'
}}
>
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>
)}
<Link
to={`/reservations/new?resourceId=${resource.id}`}
style={{
flex: 1,
display: 'inline-block',
padding: '0.5rem 1rem',
backgroundColor: '#27ae60',
color: 'white',
textDecoration: 'none',
borderRadius: '4px',
textAlign: 'center',
fontSize: '0.875rem'
}}
>
Book Now
</Link>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
);
};

View File

@ -1,361 +0,0 @@
.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;
}
}

View File

@ -1,70 +1,61 @@
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';
import { SimpleLibreBookingClient } from '../services/librebooking-api';
import { format, isToday, isTomorrow } from 'date-fns';
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(() => {
const api = new SimpleLibreBookingClient();
const loadReservations = async () => {
try {
const reservationsData = await api.getReservations();
setReservations(reservationsData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load reservations');
} finally {
setLoading(false);
}
};
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);
// Group reservations by status
const pendingReservations = reservations.filter(r => r.status === 'pending');
const confirmedReservations = reservations.filter(r => r.status === 'confirmed');
// Group upcoming reservations
const upcomingReservations = reservations.filter(r =>
r.status !== 'cancelled' && new Date(r.endTime) > new Date()
);
const todayReservations = reservations.filter(r =>
r.status !== 'cancelled' && (
isToday(new Date(r.startTime)) ||
isToday(new Date(r.endTime))
)
);
const getReservationLabel = (reservation: Reservation): string => {
const startDate = new Date(reservation.startTime);
const endDate = new Date(reservation.endTime);
if (isToday(startDate)) {
return `Today, ${format(startDate, 'h:mm a')} - ${format(endDate, 'h:mm a')}`;
} else if (isTomorrow(startDate)) {
return `Tomorrow, ${format(startDate, 'h:mm a')} - ${format(endDate, 'h:mm a')}`;
} else {
return format(startDate, 'MMM d, h:mm a') + ' - ' + format(endDate, 'h:mm a');
}
};
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) => {
const getStatusColor = (status: string): string => {
switch (status) {
case 'confirmed':
return '#27ae60';
@ -77,198 +68,236 @@ const UserDashboard: React.FC = () => {
}
};
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>;
return (
<div>
<h2>My Reservations</h2>
<div>Loading your reservations...</div>
</div>
);
}
const filteredReservations = getFilteredReservations();
if (error) {
return (
<div>
<h2>My Reservations</h2>
<div style={{ color: '#e74c3c' }}>Error: {error}</div>
</div>
);
}
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>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h2>My Reservations</h2>
<Link
to="/reservations/new"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: '#27ae60',
color: 'white',
textDecoration: 'none',
borderRadius: '4px',
fontWeight: '500'
}}
>
New Booking
</Link>
</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 style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd', textAlign: 'center' }}>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#3498db', marginBottom: '0.5rem' }}>
{upcomingReservations.length}
</div>
<div style={{ color: '#666' }}>Upcoming Bookings</div>
</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 style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd', textAlign: 'center' }}>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#f39c12', marginBottom: '0.5rem' }}>
{pendingReservations.length}
</div>
<div style={{ color: '#666' }}>Pending Confirmation</div>
</div>
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd', textAlign: 'center' }}>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#27ae60', marginBottom: '0.5rem' }}>
{confirmedReservations.length}
</div>
<div style={{ color: '#666' }}>Confirmed Bookings</div>
</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>
{/* Today's Reservations */}
{todayReservations.length > 0 && (
<div style={{ marginBottom: '2rem' }}>
<h3 style={{ color: '#2c3e50', marginBottom: '1rem' }}>Today's Reservations</h3>
<div style={{ display: 'grid', gap: '1rem' }}>
{todayReservations.map(reservation => (
<div
key={reservation.id}
style={{
backgroundColor: 'white',
padding: '1.5rem',
borderRadius: '8px',
border: '1px solid #ddd',
borderLeft: `4px solid ${getStatusColor(reservation.status)}`
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '0.5rem' }}>
<div>
<h4 style={{ margin: '0 0 0.25rem 0', color: '#2c3e50' }}>
{reservation.title}
</h4>
<div style={{ color: '#666', fontSize: '0.875rem' }}>
{reservation.resource.name}
</div>
</div>
<span
style={{
padding: '0.25rem 0.75rem',
borderRadius: '20px',
fontSize: '0.75rem',
fontWeight: '500',
backgroundColor: getStatusColor(reservation.status) + '20',
color: getStatusColor(reservation.status)
}}
>
{reservation.status}
</span>
</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 style={{ color: '#555', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
{getReservationLabel(reservation)}
</div>
<div className="reservation-actions">
{reservation.description && (
<div style={{ color: '#666', fontSize: '0.875rem', fontStyle: 'italic' }}>
{reservation.description}
</div>
)}
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
<Link
to={`/resources/${reservation.resourceId}/calendar`}
className="btn btn-secondary"
style={{
padding: '0.5rem 1rem',
backgroundColor: '#3498db',
color: 'white',
textDecoration: 'none',
borderRadius: '4px',
fontSize: '0.875rem'
}}
>
View Calendar
</Link>
{reservation.status === 'pending' && (
<div className="pending-note">
Awaiting approval
</div>
</div>
))}
</div>
</div>
)}
{/* All Reservations */}
<div>
<h3 style={{ color: '#2c3e50', marginBottom: '1rem' }}>All Reservations</h3>
{reservations.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '2rem',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #ddd'
}}>
<p style={{ color: '#666', marginBottom: '1rem' }}>
You don't have any reservations yet.
</p>
<Link
to="/resources"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: '#3498db',
color: 'white',
textDecoration: 'none',
borderRadius: '4px'
}}
>
Browse Resources
</Link>
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{reservations
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
.map(reservation => (
<div
key={reservation.id}
style={{
backgroundColor: 'white',
padding: '1.5rem',
borderRadius: '8px',
border: '1px solid #ddd',
borderLeft: `4px solid ${getStatusColor(reservation.status)}`
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '0.5rem' }}>
<div>
<h4 style={{ margin: '0 0 0.25rem 0', color: '#2c3e50' }}>
{reservation.title}
</h4>
<div style={{ color: '#666', fontSize: '0.875rem' }}>
{reservation.resource.name}
{reservation.resource.location && `${reservation.resource.location}`}
</div>
)}
</div>
<span
style={{
padding: '0.25rem 0.75rem',
borderRadius: '20px',
fontSize: '0.75rem',
fontWeight: '500',
backgroundColor: getStatusColor(reservation.status) + '20',
color: getStatusColor(reservation.status)
}}
>
{reservation.status}
</span>
</div>
<div style={{ color: '#555', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
{getReservationLabel(reservation)}
</div>
{reservation.description && (
<div style={{ color: '#666', fontSize: '0.875rem', fontStyle: 'italic', marginBottom: '0.5rem' }}>
{reservation.description}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ color: '#888', fontSize: '0.75rem' }}>
Booked on {format(new Date(reservation.createdAt), 'MMM d, yyyy')}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Link
to={`/resources/${reservation.resourceId}/calendar`}
style={{
padding: '0.5rem 1rem',
backgroundColor: '#3498db',
color: 'white',
textDecoration: 'none',
borderRadius: '4px',
fontSize: '0.875rem'
}}
>
View Calendar
</Link>
</div>
</div>
</div>
))}

View File

@ -1,131 +0,0 @@
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;
},
};

View File

@ -1,133 +1,241 @@
import { Resource, Reservation, CreateReservationRequest } from '../types';
import { Resource, CreateReservationRequest, Reservation } from '../types';
export interface LibreBookingAPIConfig {
baseURL: string;
apiKey?: string;
timeout?: number;
}
class LibreBookingAPIClient {
private config: LibreBookingAPIConfig;
export class SimpleLibreBookingClient {
private baseUrl: string;
private sessionToken: string | null = null;
private userId: string | null = null;
constructor(config: LibreBookingAPIConfig) {
this.config = config;
this.baseUrl = config.baseURL.replace(/\/$/, ''); // Remove trailing slash
constructor(baseUrl?: string) {
this.baseUrl = baseUrl || process.env.REACT_APP_LIBREBOOKING_API_URL || 'http://localhost:8080/Web';
}
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
async authenticate(username: string, password: string): Promise<boolean> {
try {
console.log('Attempting authentication with:', { username, baseUrl: this.baseUrl });
const response = await fetch(`${this.baseUrl}/Services/index.php/Authentication/Authenticate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
console.log('Authentication response status:', response.status);
const data = await response.json();
console.log('Authentication response data:', data);
if (response.ok && data.isAuthenticated) {
this.sessionToken = data.sessionToken;
this.userId = data.userId?.toString();
console.log('Authentication successful:', { sessionToken: this.sessionToken, userId: this.userId });
return true;
} else {
console.log('Authentication failed:', { status: response.status, data });
return false;
}
} catch (error) {
console.error('Authentication error:', error);
return false;
}
}
async getResources(): Promise<Resource[]> {
if (!this.sessionToken) {
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
if (username && password) {
const authenticated = await this.authenticate(username, password);
if (!authenticated) {
throw new Error('Authentication failed');
}
}
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.sessionToken && this.userId) {
headers['X-Booked-SessionToken'] = this.sessionToken;
headers['X-Booked-UserId'] = this.userId;
}
const response = await fetch(`${this.baseUrl}/Services/index.php/Resources/`, {
headers,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('Resources response:', data);
return data.resources || [];
}
async getResource(id: string): Promise<Resource | null> {
if (!this.sessionToken) {
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
if (username && password) {
const authenticated = await this.authenticate(username, password);
if (!authenticated) {
throw new Error('Authentication failed');
}
}
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.sessionToken && this.userId) {
headers['X-Booked-SessionToken'] = this.sessionToken;
headers['X-Booked-UserId'] = this.userId;
}
const response = await fetch(`${this.baseUrl}/Services/index.php/Resources/${id}`, {
headers,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('Resource response:', data);
return data.resource || null;
}
async getReservations(resourceId?: string): Promise<Reservation[]> {
if (!this.sessionToken) {
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
if (username && password) {
const authenticated = await this.authenticate(username, password);
if (!authenticated) {
throw new Error('Authentication failed');
}
}
}
const url = resourceId
? `${this.baseUrl}/Services/index.php/Reservations/?resourceId=${resourceId}`
: `${this.baseUrl}/Services/index.php/Reservations/`;
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}`;
if (this.sessionToken && this.userId) {
headers['X-Booked-SessionToken'] = this.sessionToken;
headers['X-Booked-UserId'] = this.userId;
}
try {
const response = await fetch(url, {
...options,
headers,
signal: AbortSignal.timeout(this.config.timeout || 10000),
});
const response = await fetch(url, {
headers,
});
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;
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
// 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}`);
const data = await response.json();
console.log('Reservations response:', data);
return data.reservations || [];
}
async createReservation(data: CreateReservationRequest): Promise<Reservation> {
return this.makeRequest<Reservation>('/api/reservations', {
if (!this.sessionToken) {
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
if (username && password) {
const authenticated = await this.authenticate(username, password);
if (!authenticated) {
throw new Error('Authentication failed');
}
}
}
// Use the exact field names expected by LibreBooking API
const requestData = {
resourceId: data.resourceId,
startDateTime: data.startDateTime,
endDateTime: data.endDateTime,
title: data.title,
description: data.description || '',
userId: this.getUserId() || undefined,
allowParticipation: true,
termsAccepted: true
};
console.log('Creating reservation with data:', requestData);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.sessionToken && this.userId) {
headers['X-Booked-SessionToken'] = this.sessionToken;
headers['X-Booked-UserId'] = this.userId;
}
const response = await fetch(`${this.baseUrl}/Services/index.php/Reservations/`, {
method: 'POST',
body: JSON.stringify(data),
headers,
body: JSON.stringify(requestData),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reservation = await response.json();
return reservation;
}
async updateReservationStatus(
id: string,
status: 'confirmed' | 'cancelled'
): Promise<Reservation> {
return this.makeRequest<Reservation>(`/api/reservations/${id}/status`, {
method: 'PATCH',
async updateReservationStatus(id: string, status: 'confirmed' | 'cancelled'): Promise<Reservation> {
if (!this.sessionToken) {
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
if (username && password) {
const authenticated = await this.authenticate(username, password);
if (!authenticated) {
throw new Error('Authentication failed');
}
}
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.sessionToken && this.userId) {
headers['X-Booked-SessionToken'] = this.sessionToken;
headers['X-Booked-UserId'] = this.userId;
}
const response = await fetch(`${this.baseUrl}/Services/index.php/Reservations/${id}`, {
method: 'PUT',
headers,
body: JSON.stringify({ status }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reservation = await response.json();
return reservation;
}
async cancelReservation(id: string): Promise<Reservation> {
return this.makeRequest<Reservation>(`/api/reservations/${id}/cancel`, {
method: 'POST',
});
getUserId(): string | null {
return this.userId || null;
}
// 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,
};
}

View File

@ -31,8 +31,11 @@ export interface User {
export interface CreateReservationRequest {
resourceId: string;
startTime: Date;
endTime: Date;
startDateTime: string; // Match field name from LibreBooking API
endDateTime: string; // Match field name from LibreBooking API
title: string;
description?: string;
userId?: string; // Allow null initially
allowParticipation?: boolean;
termsAccepted?: boolean;
}

37
test-api.js Normal file
View File

@ -0,0 +1,37 @@
// Test script to verify API connection
const baseUrl = 'http://localhost:8080/Web';
async function testAuth() {
console.log('Testing authentication...');
const response = await fetch(`${baseUrl}/Services/index.php/Authentication/Authenticate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: 'admin',
password: 'password'
}),
});
const data = await response.json();
console.log('Auth response:', data);
if (data.isAuthenticated) {
console.log('Testing resources with session token...');
const resourcesResponse = await fetch(`${baseUrl}/Services/index.php/Resources/`, {
headers: {
'Content-Type': 'application/json',
'X-Booked-SessionToken': data.sessionToken,
'X-Booked-UserId': data.userId,
},
});
const resourcesData = await resourcesResponse.json();
console.log('Resources response:', resourcesData);
}
}
testAuth().catch(console.error);