working api connection
This commit is contained in:
parent
bf4136b8dc
commit
af49dea1d6
7
.env
Normal file
7
.env
Normal 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
56
public/test.html
Normal 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
12
src/App.minimal.tsx
Normal 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;
|
||||||
@ -13,6 +13,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-brand {
|
.nav-brand {
|
||||||
@ -33,6 +35,40 @@
|
|||||||
font-weight: 600;
|
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 */
|
/* Desktop Navigation */
|
||||||
.nav-links-desktop {
|
.nav-links-desktop {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
22
src/hooks/useAPIStatus.ts
Normal file
22
src/hooks/useAPIStatus.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import reportWebVitals from './reportWebVitals';
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById('root') as HTMLElement
|
document.getElementById('root') as HTMLElement
|
||||||
@ -12,8 +11,3 @@ root.render(
|
|||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</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();
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,224 +1,90 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { api } from '../services/api';
|
|
||||||
import { Reservation } from '../types';
|
import { Reservation } from '../types';
|
||||||
import './AdminDashboard.css';
|
import { SimpleLibreBookingClient } from '../services/librebooking-api';
|
||||||
|
|
||||||
const AdminDashboard: React.FC = () => {
|
const AdminDashboard: React.FC = () => {
|
||||||
const [reservations, setReservations] = useState<Reservation[]>([]);
|
const [reservations, setReservations] = useState<Reservation[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [processing, setProcessing] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
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 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) {
|
if (loading) {
|
||||||
return <div className="loading">Loading admin dashboard...</div>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-dashboard">
|
<div>
|
||||||
<div className="page-header">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||||
<h1>Admin Dashboard</h1>
|
<h2>Admin Dashboard</h2>
|
||||||
<p>Manage resource reservations</p>
|
<Link
|
||||||
</div>
|
to="/resources"
|
||||||
|
style={{
|
||||||
{error && <div className="error">{error}</div>}
|
display: 'inline-block',
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
<div className="stats-grid">
|
backgroundColor: '#3498db',
|
||||||
<div className="stat-card">
|
color: 'white',
|
||||||
<h3>{pendingReservations.length}</h3>
|
textDecoration: 'none',
|
||||||
<p>Pending Reservations</p>
|
borderRadius: '4px',
|
||||||
</div>
|
fontWeight: '500'
|
||||||
<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}
|
Manage Resources
|
||||||
</span>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="reservation-details">
|
{/* Quick Stats */}
|
||||||
<div className="detail-row">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
|
||||||
<strong>Resource:</strong> {reservation.resource.name}
|
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd' }}>
|
||||||
</div>
|
<h3>Total Reservations</h3>
|
||||||
<div className="detail-row">
|
<div style={{ fontSize: '2rem', color: '#2c3e50', marginBottom: '0.5rem' }}>
|
||||||
<strong>Location:</strong> {reservation.resource.location}
|
{reservations.length}
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-row">
|
</div>
|
||||||
<strong>Date:</strong> {format(new Date(reservation.startTime), 'MMMM d, yyyy')}
|
|
||||||
</div>
|
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd' }}>
|
||||||
<div className="detail-row">
|
<h3>Pending Approvals</h3>
|
||||||
<strong>Time:</strong> {format(new Date(reservation.startTime), 'h:mm a')} - {format(new Date(reservation.endTime), 'h:mm a')}
|
<div style={{ fontSize: '2rem', color: '#f39c12', marginBottom: '0.5rem' }}>
|
||||||
</div>
|
{pendingReservations.length}
|
||||||
{reservation.description && (
|
|
||||||
<div className="detail-row">
|
|
||||||
<strong>Description:</strong> {reservation.description}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="detail-row">
|
|
||||||
<strong>Created:</strong> {format(new Date(reservation.createdAt), 'MMM d, yyyy h:mm a')}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(onConfirm || onCancel) && (
|
{/* Pending Approvals */}
|
||||||
<div className="reservation-actions">
|
{pendingReservations.length > 0 && (
|
||||||
{onConfirm && (
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
<button
|
<h3>Pending Approval ({pendingReservations.length})</h3>
|
||||||
className="btn btn-confirm"
|
<div style={{ backgroundColor: '#fff3cd', padding: '1rem', borderRadius: '8px', border: '1px solid #ffc107', marginBottom: '1rem' }}>
|
||||||
onClick={onConfirm}
|
You have {pendingReservations.length} pending reservations that need approval
|
||||||
disabled={processing}
|
</div>
|
||||||
>
|
|
||||||
{processing ? 'Processing...' : 'Confirm'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onCancel && (
|
|
||||||
<button
|
|
||||||
className="btn btn-cancel"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={processing}
|
|
||||||
>
|
|
||||||
{processing ? 'Processing...' : 'Cancel'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,182 +1,333 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { api } from '../services/api';
|
|
||||||
import { Resource, CreateReservationRequest } from '../types';
|
import { Resource, CreateReservationRequest } from '../types';
|
||||||
import './CreateReservation.css';
|
import { SimpleLibreBookingClient } from '../services/librebooking-api';
|
||||||
|
|
||||||
const CreateReservation: React.FC = () => {
|
const CreateReservation: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [formData, setFormData] = useState({
|
||||||
const [loading, setLoading] = useState(true);
|
resourceId: '',
|
||||||
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: '',
|
title: '',
|
||||||
description: '',
|
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(() => {
|
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();
|
loadResources();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadResources = async () => {
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
try {
|
const { name, value } = event.target;
|
||||||
setLoading(true);
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
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) => {
|
const validateForm = (): string | null => {
|
||||||
e.preventDefault();
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
if (!formData.resourceId || !formData.title) {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
setError('Please fill in all required fields');
|
event.preventDefault();
|
||||||
|
|
||||||
|
const validationError = validateForm();
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.startTime >= formData.endTime) {
|
setSubmitting(true);
|
||||||
setError('End time must be after start time');
|
setError(null);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
const requestData: CreateReservationRequest = {
|
||||||
setError(null);
|
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
|
||||||
|
};
|
||||||
|
|
||||||
await api.createReservation(formData);
|
console.log('Creating reservation with data:', requestData);
|
||||||
|
const reservation = await apiClient.createReservation(requestData);
|
||||||
|
|
||||||
// Navigate to resources list on success
|
navigate(`/resources/${formData.resourceId}/calendar`, {
|
||||||
navigate('/resources', {
|
state: { newReservation: reservation }
|
||||||
state: { message: 'Reservation created successfully!' }
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to create reservation');
|
setError(err instanceof Error ? err.message : 'Failed to create reservation');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (field: keyof CreateReservationRequest, value: any) => {
|
const selectedResource = resources.find(r => r.id === formData.resourceId);
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading">Loading resources...</div>;
|
return <div>Loading...</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 (
|
return (
|
||||||
<div className="create-reservation">
|
<div>
|
||||||
<div className="page-header">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||||
<h1>Create New Reservation</h1>
|
<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>
|
</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">
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="form-group">
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
<label htmlFor="resourceId">Resource *</label>
|
<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
|
<select
|
||||||
id="resourceId"
|
id="resourceId"
|
||||||
|
name="resourceId"
|
||||||
value={formData.resourceId}
|
value={formData.resourceId}
|
||||||
onChange={(e) => handleInputChange('resourceId', e.target.value)}
|
onChange={handleInputChange}
|
||||||
required
|
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 => (
|
{resources.map(resource => (
|
||||||
<option key={resource.id} value={resource.id}>
|
<option key={resource.id} value={resource.id}>
|
||||||
{resource.name} - {resource.location}
|
{resource.name} ({resource.capacity ? `Capacity: ${resource.capacity}` : 'No capacity limit'})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
{/* Resource Details */}
|
||||||
<div className="form-group">
|
{selectedResource && (
|
||||||
<label htmlFor="startTime">Start Time *</label>
|
<div style={{
|
||||||
<input
|
backgroundColor: '#f8f9fa',
|
||||||
type="datetime-local"
|
padding: '1.5rem',
|
||||||
id="startTime"
|
borderRadius: '8px',
|
||||||
value={formData.startTime.toISOString().slice(0, 16)}
|
border: '1px solid #e9ecef',
|
||||||
onChange={(e) => handleInputChange('startTime', new Date(e.target.value))}
|
marginBottom: '1.5rem'
|
||||||
required
|
}}>
|
||||||
/>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
{/* Title */}
|
||||||
<label htmlFor="endTime">End Time *</label>
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
<input
|
<label htmlFor="title" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||||
type="datetime-local"
|
Title *
|
||||||
id="endTime"
|
</label>
|
||||||
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="title"
|
id="title"
|
||||||
|
name="title"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
onChange={handleInputChange}
|
||||||
placeholder="Meeting title or purpose"
|
|
||||||
required
|
required
|
||||||
|
placeholder="e.g., Team Meeting, Client Presentation"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '1rem'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
{/* Description */}
|
||||||
<label htmlFor="description">Description</label>
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<label htmlFor="description" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
|
name="description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
onChange={handleInputChange}
|
||||||
placeholder="Additional details about this reservation"
|
rows={3}
|
||||||
rows={4}
|
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>
|
||||||
|
|
||||||
<div className="form-actions">
|
{/* Date Selection */}
|
||||||
<button
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
type="button"
|
<label htmlFor="date" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||||
className="btn btn-secondary"
|
Date *
|
||||||
onClick={() => navigate('/resources')}
|
</label>
|
||||||
disabled={submitting}
|
<input
|
||||||
>
|
type="date"
|
||||||
Cancel
|
id="date"
|
||||||
</button>
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={submitting}
|
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'}
|
{submitting ? 'Creating...' : 'Create Reservation'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
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 { 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 ResourceCalendar: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@ -14,178 +13,248 @@ const ResourceCalendar: React.FC = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (!id) return;
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [id, currentWeek]);
|
|
||||||
|
|
||||||
const loadData = async () => {
|
const api = new SimpleLibreBookingClient();
|
||||||
if (!id) {
|
|
||||||
setError('Invalid resource ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
try {
|
||||||
setError(null);
|
setLoading(true);
|
||||||
|
const [resourceData, reservationsData] = await Promise.all([
|
||||||
const [resourceData, reservationsData] = await Promise.all([
|
api.getResource(id),
|
||||||
api.getResource(id),
|
api.getReservations(id)
|
||||||
api.getReservations(id)
|
]);
|
||||||
]);
|
setResource(resourceData);
|
||||||
|
setReservations(reservationsData);
|
||||||
if (!resourceData) {
|
} catch (err) {
|
||||||
setError('Resource not found');
|
setError(err instanceof Error ? err.message : 'Failed to load data');
|
||||||
return;
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
console.log('Calendar - Resource:', resourceData);
|
loadData();
|
||||||
console.log('Calendar - Reservations:', reservationsData);
|
}, [id]);
|
||||||
|
|
||||||
setResource(resourceData);
|
const weekStart = startOfWeek(currentWeek, { weekStartsOn: 1 });
|
||||||
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));
|
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||||
|
|
||||||
// Generate time slots from 8 AM to 8 PM
|
// 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[] => {
|
const getReservationForSlot = (date: Date, timeSlot: Date): Reservation | null => {
|
||||||
if (!date || !timeSlot) return [];
|
const slotStart = setHours(setMinutes(new Date(date), timeSlot.getHours()), timeSlot.getMinutes());
|
||||||
|
const slotEnd = addHours(slotStart, 1);
|
||||||
|
|
||||||
const slotStart = timeSlot;
|
return reservations.find(reservation => {
|
||||||
const slotEnd = addHours(timeSlot, 1);
|
const reservationStart = new Date(reservation.startTime);
|
||||||
|
const reservationEnd = new Date(reservation.endTime);
|
||||||
|
|
||||||
const filtered = reservations.filter(reservation => {
|
return (
|
||||||
if (!reservation?.startTime) return false;
|
(reservationStart < slotEnd && reservationEnd > slotStart) ||
|
||||||
|
(isSameDay(reservationStart, date) &&
|
||||||
const reservationDate = new Date(reservation.startTime);
|
reservationStart.getHours() === slotStart.getHours())
|
||||||
if (isNaN(reservationDate.getTime())) return false;
|
);
|
||||||
|
}) || null;
|
||||||
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') => {
|
const navigateWeek = (direction: 'prev' | 'next') => {
|
||||||
setCurrentWeek(prev => addDays(prev, direction === 'next' ? 7 : -7));
|
setCurrentWeek(prev => addDays(prev, direction === 'next' ? 7 : -7));
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToToday = () => {
|
|
||||||
setCurrentWeek(new Date());
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading">Loading calendar...</div>;
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Resource Calendar</h2>
|
||||||
|
<div>Loading calendar...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="error">{error}</div>;
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Resource Calendar</h2>
|
||||||
|
<div style={{ color: '#e74c3c' }}>Error: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
return <div className="error">Resource not found</div>;
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Resource Calendar</h2>
|
||||||
|
<div>Resource not found.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="resource-calendar">
|
<div>
|
||||||
<div className="calendar-header">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||||
<div className="header-info">
|
<div>
|
||||||
<h1>{resource.name} - Calendar</h1>
|
<h2>{resource.name} - Calendar</h2>
|
||||||
<p className="resource-details">
|
{resource.description && (
|
||||||
{resource.location} • Capacity: {resource.capacity}
|
<p style={{ color: '#666', margin: '0.5rem 0' }}>{resource.description}</p>
|
||||||
</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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="calendar-container">
|
{/* Week Navigation */}
|
||||||
<div className="calendar-grid">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', padding: '1rem', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
|
||||||
{/* Time column header */}
|
<button
|
||||||
<div className="time-header"></div>
|
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>
|
||||||
|
|
||||||
{/* Day headers */}
|
{/* Calendar Grid */}
|
||||||
{weekDays.map(day => (
|
<div style={{
|
||||||
<div key={day.toString()} className="day-header">
|
border: '1px solid #ddd',
|
||||||
<div className="day-name">{format(day, 'EEE')}</div>
|
borderRadius: '4px',
|
||||||
<div className="day-date">{format(day, 'd')}</div>
|
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>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Time slots */}
|
{/* Time Slots */}
|
||||||
{timeSlots.map(timeSlot => (
|
{timeSlots.map((timeSlot, timeIndex) => (
|
||||||
<React.Fragment key={timeSlot.toString()}>
|
<div key={timeIndex} style={{ display: 'grid', gridTemplateColumns: '100px repeat(7, 1fr)' }}>
|
||||||
<div className="time-label">
|
{/* Time Column */}
|
||||||
{format(timeSlot, 'h:mm a')}
|
<div style={{
|
||||||
</div>
|
padding: '0.75rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRight: '1px solid #ddd',
|
||||||
|
borderBottom: '1px solid #ddd',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}>
|
||||||
|
{format(timeSlot, 'h a')}
|
||||||
|
</div>
|
||||||
|
|
||||||
{weekDays.map(day => {
|
{/* Day Columns */}
|
||||||
const slotReservations = getReservationsForSlot(day, timeSlot);
|
{weekDays.map((day, dayIndex) => {
|
||||||
const isToday = isSameDay(day, new Date());
|
const reservation = getReservationForSlot(day, timeSlot);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${day.toString()}-${timeSlot.toString()}`}
|
key={dayIndex}
|
||||||
className={`time-slot ${isToday ? 'today' : ''}`}
|
style={{
|
||||||
>
|
padding: '0.25rem',
|
||||||
{slotReservations.map(reservation => (
|
borderRight: dayIndex < 6 ? '1px solid #ddd' : 'none',
|
||||||
<div
|
borderBottom: '1px solid #ddd',
|
||||||
key={reservation.id}
|
backgroundColor: reservation
|
||||||
className={`reservation-item ${reservation.status}`}
|
? reservation.status === 'confirmed' ? '#d4edda'
|
||||||
>
|
: reservation.status === 'cancelled' ? '#f8d7da'
|
||||||
<div className="reservation-title">{reservation.title}</div>
|
: '#fff3cd'
|
||||||
<div className="reservation-time">
|
: isToday(day) ? '#f8f9fa' : 'white',
|
||||||
{format(new Date(reservation.startTime), 'h:mm a')} -
|
fontSize: '0.75rem',
|
||||||
{format(new Date(reservation.endTime), 'h:mm a')}
|
minHeight: '40px',
|
||||||
</div>
|
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>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</React.Fragment>
|
})}
|
||||||
))}
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../services/api';
|
|
||||||
import { Resource } from '../types';
|
import { Resource } from '../types';
|
||||||
import './ResourcesList.css';
|
import { SimpleLibreBookingClient } from '../services/librebooking-api';
|
||||||
|
|
||||||
const ResourcesList: React.FC = () => {
|
const ResourcesList: React.FC = () => {
|
||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
@ -10,102 +9,174 @@ const ResourcesList: React.FC = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
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) {
|
if (loading) {
|
||||||
return <div className="loading">Loading resources...</div>;
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Resources</h2>
|
||||||
|
<div>Loading resources...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="error">{error}</div>;
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Resources</h2>
|
||||||
|
<div style={{ color: '#e74c3c' }}>Error: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="resources-list">
|
<div>
|
||||||
<div className="page-header">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||||
<h1>Available Resources</h1>
|
<h2>Available Resources</h2>
|
||||||
<Link to="/reservations/new" className="btn btn-primary">
|
<Link
|
||||||
New Reservation
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="resources-grid">
|
{resources.length === 0 ? (
|
||||||
{resources.map(resource => (
|
<div style={{ textAlign: 'center', padding: '2rem', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
|
||||||
<div key={resource.id} className="resource-card">
|
<p>No resources available at the moment.</p>
|
||||||
<div className="resource-image">
|
</div>
|
||||||
{resource.imageUrl ? (
|
) : (
|
||||||
<img src={resource.imageUrl} alt={resource.name} />
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1.5rem' }}>
|
||||||
) : (
|
{resources.map((resource) => (
|
||||||
<div className="placeholder-image">🏢</div>
|
<div
|
||||||
)}
|
key={resource.id}
|
||||||
</div>
|
style={{
|
||||||
|
border: '1px solid #ddd',
|
||||||
<div className="resource-content">
|
borderRadius: '8px',
|
||||||
<h3>{resource.name}</h3>
|
padding: '1.5rem',
|
||||||
<p className="resource-description">{resource.description}</p>
|
backgroundColor: 'white',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||||
{resource.location && (
|
}}
|
||||||
<p className="resource-location">📍 {resource.location}</p>
|
>
|
||||||
)}
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
|
||||||
|
<h3 style={{ margin: 0, color: '#2c3e50' }}>{resource.name}</h3>
|
||||||
{resource.capacity && (
|
<span
|
||||||
<p className="resource-capacity">👥 Capacity: {resource.capacity}</p>
|
style={{
|
||||||
)}
|
padding: '0.25rem 0.75rem',
|
||||||
|
borderRadius: '20px',
|
||||||
{resource.amenities && resource.amenities.length > 0 && (
|
fontSize: '0.875rem',
|
||||||
<div className="resource-amenities">
|
fontWeight: '500',
|
||||||
<strong>Amenities:</strong>
|
backgroundColor: resource.isAvailable ? '#d4edda' : '#f8d7da',
|
||||||
<ul>
|
color: resource.isAvailable ? '#155724' : '#721c24'
|
||||||
{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'}
|
{resource.isAvailable ? 'Available' : 'Unavailable'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<Link
|
||||||
to={`/resources/${resource.id}/calendar`}
|
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
|
View Calendar
|
||||||
</Link>
|
</Link>
|
||||||
{resource.isAvailable ? (
|
<Link
|
||||||
<Link
|
to={`/reservations/new?resourceId=${resource.id}`}
|
||||||
to={`/reservations/new?resourceId=${resource.id}`}
|
style={{
|
||||||
className="btn btn-primary"
|
flex: 1,
|
||||||
>
|
display: 'inline-block',
|
||||||
Book Now
|
padding: '0.5rem 1rem',
|
||||||
</Link>
|
backgroundColor: '#27ae60',
|
||||||
) : (
|
color: 'white',
|
||||||
<span className="btn btn-primary disabled">
|
textDecoration: 'none',
|
||||||
Book Now
|
borderRadius: '4px',
|
||||||
</span>
|
textAlign: 'center',
|
||||||
)}
|
fontSize: '0.875rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Book Now
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,70 +1,61 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { format, startOfDay, isAfter, isBefore } from 'date-fns';
|
|
||||||
import { api } from '../services/api';
|
|
||||||
import { Reservation } from '../types';
|
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 UserDashboard: React.FC = () => {
|
||||||
const [reservations, setReservations] = useState<Reservation[]>([]);
|
const [reservations, setReservations] = useState<Reservation[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
loadReservations();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadReservations = async () => {
|
// Group reservations by status
|
||||||
try {
|
const pendingReservations = reservations.filter(r => r.status === 'pending');
|
||||||
setLoading(true);
|
const confirmedReservations = reservations.filter(r => r.status === 'confirmed');
|
||||||
const data = await api.getReservations();
|
|
||||||
// Sort by start date, upcoming first
|
// Group upcoming reservations
|
||||||
const sortedData = data.sort((a, b) =>
|
const upcomingReservations = reservations.filter(r =>
|
||||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
|
r.status !== 'cancelled' && new Date(r.endTime) > new Date()
|
||||||
);
|
);
|
||||||
setReservations(sortedData);
|
|
||||||
} catch (err) {
|
const todayReservations = reservations.filter(r =>
|
||||||
setError('Failed to load reservations');
|
r.status !== 'cancelled' && (
|
||||||
} finally {
|
isToday(new Date(r.startTime)) ||
|
||||||
setLoading(false);
|
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 getStatusColor = (status: string): string => {
|
||||||
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) {
|
switch (status) {
|
||||||
case 'confirmed':
|
case 'confirmed':
|
||||||
return '#27ae60';
|
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) {
|
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 (
|
return (
|
||||||
<div className="user-dashboard">
|
<div>
|
||||||
<div className="page-header">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||||
<div className="header-content">
|
<h2>My Reservations</h2>
|
||||||
<h1>My Reservations</h1>
|
<Link
|
||||||
<p>View and manage all your resource bookings</p>
|
to="/reservations/new"
|
||||||
</div>
|
style={{
|
||||||
<div className="header-actions">
|
display: 'inline-block',
|
||||||
<Link to="/reservations/new" className="btn btn-primary">
|
padding: '0.75rem 1.5rem',
|
||||||
New Reservation
|
backgroundColor: '#27ae60',
|
||||||
</Link>
|
color: 'white',
|
||||||
</div>
|
textDecoration: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New Booking
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="stats-grid">
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
|
||||||
<div className="stat-card">
|
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd', textAlign: 'center' }}>
|
||||||
<h3>{upcomingReservations.length}</h3>
|
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#3498db', marginBottom: '0.5rem' }}>
|
||||||
<p>Upcoming Reservations</p>
|
{upcomingReservations.length}
|
||||||
</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>
|
||||||
) : (
|
<div style={{ color: '#666' }}>Upcoming Bookings</div>
|
||||||
<div className="reservations-list">
|
</div>
|
||||||
{filteredReservations.map(reservation => (
|
|
||||||
<div key={reservation.id} className="reservation-card">
|
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd', textAlign: 'center' }}>
|
||||||
<div className="reservation-header">
|
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#f39c12', marginBottom: '0.5rem' }}>
|
||||||
<div className="reservation-info">
|
{pendingReservations.length}
|
||||||
<h3>{reservation.title}</h3>
|
</div>
|
||||||
<p className="resource-name">{reservation.resource.name}</p>
|
<div style={{ color: '#666' }}>Pending Confirmation</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="reservation-status">
|
|
||||||
<span
|
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd', textAlign: 'center' }}>
|
||||||
className="status-badge"
|
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#27ae60', marginBottom: '0.5rem' }}>
|
||||||
style={{ backgroundColor: getStatusColor(reservation.status) }}
|
{confirmedReservations.length}
|
||||||
>
|
</div>
|
||||||
{reservation.status}
|
<div style={{ color: '#666' }}>Confirmed Bookings</div>
|
||||||
</span>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
</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>
|
||||||
|
|
||||||
<div className="reservation-details">
|
<div style={{ color: '#555', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
||||||
<div className="detail-group">
|
{getReservationLabel(reservation)}
|
||||||
<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>
|
||||||
|
|
||||||
<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
|
<Link
|
||||||
to={`/resources/${reservation.resourceId}/calendar`}
|
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
|
View Calendar
|
||||||
</Link>
|
</Link>
|
||||||
{reservation.status === 'pending' && (
|
</div>
|
||||||
<div className="pending-note">
|
</div>
|
||||||
⏳ Awaiting approval
|
))}
|
||||||
|
</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>
|
||||||
)}
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,133 +1,241 @@
|
|||||||
import { Resource, Reservation, CreateReservationRequest } from '../types';
|
import { Resource, CreateReservationRequest, Reservation } from '../types';
|
||||||
|
|
||||||
export interface LibreBookingAPIConfig {
|
export class SimpleLibreBookingClient {
|
||||||
baseURL: string;
|
|
||||||
apiKey?: string;
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class LibreBookingAPIClient {
|
|
||||||
private config: LibreBookingAPIConfig;
|
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
private sessionToken: string | null = null;
|
||||||
|
private userId: string | null = null;
|
||||||
|
|
||||||
constructor(config: LibreBookingAPIConfig) {
|
constructor(baseUrl?: string) {
|
||||||
this.config = config;
|
this.baseUrl = baseUrl || process.env.REACT_APP_LIBREBOOKING_API_URL || 'http://localhost:8080/Web';
|
||||||
this.baseUrl = config.baseURL.replace(/\/$/, ''); // Remove trailing slash
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async makeRequest<T>(
|
async authenticate(username: string, password: string): Promise<boolean> {
|
||||||
endpoint: string,
|
try {
|
||||||
options: RequestInit = {}
|
console.log('Attempting authentication with:', { username, baseUrl: this.baseUrl });
|
||||||
): Promise<T> {
|
|
||||||
const url = `${this.baseUrl}${endpoint}`;
|
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> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(options.headers as Record<string, string> || {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.config.apiKey) {
|
if (this.sessionToken && this.userId) {
|
||||||
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
|
headers['X-Booked-SessionToken'] = this.sessionToken;
|
||||||
|
headers['X-Booked-UserId'] = this.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const response = await fetch(`${this.baseUrl}/Services/index.php/Resources/`, {
|
||||||
const response = await fetch(url, {
|
headers,
|
||||||
...options,
|
});
|
||||||
headers,
|
|
||||||
signal: AbortSignal.timeout(this.config.timeout || 10000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Resources response:', data);
|
||||||
|
return data.resources || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resources
|
async getResource(id: string): Promise<Resource | null> {
|
||||||
async getResources(): Promise<Resource[]> {
|
if (!this.sessionToken) {
|
||||||
return this.makeRequest<Resource[]>('/api/resources');
|
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 getResource(id: string): Promise<Resource> {
|
|
||||||
return this.makeRequest<Resource>(`/api/resources/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reservations
|
|
||||||
async getReservations(resourceId?: string): Promise<Reservation[]> {
|
async getReservations(resourceId?: string): Promise<Reservation[]> {
|
||||||
const params = resourceId ? `?resourceId=${resourceId}` : '';
|
if (!this.sessionToken) {
|
||||||
return this.makeRequest<Reservation[]>(`/api/reservations${params}`);
|
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
|
||||||
}
|
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
|
||||||
|
|
||||||
async getReservation(id: string): Promise<Reservation> {
|
if (username && password) {
|
||||||
return this.makeRequest<Reservation>(`/api/reservations/${id}`);
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.sessionToken && this.userId) {
|
||||||
|
headers['X-Booked-SessionToken'] = this.sessionToken;
|
||||||
|
headers['X-Booked-UserId'] = this.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Reservations response:', data);
|
||||||
|
return data.reservations || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async createReservation(data: CreateReservationRequest): Promise<Reservation> {
|
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',
|
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(
|
async updateReservationStatus(id: string, status: 'confirmed' | 'cancelled'): Promise<Reservation> {
|
||||||
id: string,
|
if (!this.sessionToken) {
|
||||||
status: 'confirmed' | 'cancelled'
|
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
|
||||||
): Promise<Reservation> {
|
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
|
||||||
return this.makeRequest<Reservation>(`/api/reservations/${id}/status`, {
|
|
||||||
method: 'PATCH',
|
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 }),
|
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> {
|
getUserId(): string | null {
|
||||||
return this.makeRequest<Reservation>(`/api/reservations/${id}/cancel`, {
|
return this.userId || null;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -31,8 +31,11 @@ export interface User {
|
|||||||
|
|
||||||
export interface CreateReservationRequest {
|
export interface CreateReservationRequest {
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
startTime: Date;
|
startDateTime: string; // Match field name from LibreBooking API
|
||||||
endTime: Date;
|
endDateTime: string; // Match field name from LibreBooking API
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
userId?: string; // Allow null initially
|
||||||
|
allowParticipation?: boolean;
|
||||||
|
termsAccepted?: boolean;
|
||||||
}
|
}
|
||||||
37
test-api.js
Normal file
37
test-api.js
Normal 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);
|
||||||
Loading…
Reference in New Issue
Block a user