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;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
@ -33,6 +35,40 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* API Status Indicator */
|
||||
.api-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #ecf0f1;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #95a5a6;
|
||||
}
|
||||
|
||||
.status-checking {
|
||||
background-color: #f39c12;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-available {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.status-unavailable {
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Desktop Navigation */
|
||||
.nav-links-desktop {
|
||||
display: flex;
|
||||
|
||||
22
src/hooks/useAPIStatus.ts
Normal file
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 './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
@ -12,8 +11,3 @@ root.render(
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
|
||||
@ -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 { format } from 'date-fns';
|
||||
import { api } from '../services/api';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Reservation } from '../types';
|
||||
import './AdminDashboard.css';
|
||||
import { SimpleLibreBookingClient } from '../services/librebooking-api';
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const [reservations, setReservations] = useState<Reservation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const apiClient = new SimpleLibreBookingClient();
|
||||
|
||||
const loadReservations = async () => {
|
||||
try {
|
||||
const reservationsData = await apiClient.getReservations();
|
||||
setReservations(reservationsData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load reservations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadReservations();
|
||||
}, []);
|
||||
|
||||
const loadReservations = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getReservations();
|
||||
// Sort by creation date, newest first
|
||||
const sortedData = data.sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
setReservations(sortedData);
|
||||
} catch (err) {
|
||||
setError('Failed to load reservations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReservation = async (reservationId: string) => {
|
||||
try {
|
||||
setProcessing(reservationId);
|
||||
await api.updateReservationStatus(reservationId, 'confirmed');
|
||||
|
||||
// Update local state
|
||||
setReservations(prev =>
|
||||
prev.map(r =>
|
||||
r.id === reservationId
|
||||
? { ...r, status: 'confirmed' as const }
|
||||
: r
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
setError('Failed to confirm reservation');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelReservation = async (reservationId: string) => {
|
||||
try {
|
||||
setProcessing(reservationId);
|
||||
await api.updateReservationStatus(reservationId, 'cancelled');
|
||||
|
||||
// Update local state
|
||||
setReservations(prev =>
|
||||
prev.map(r =>
|
||||
r.id === reservationId
|
||||
? { ...r, status: 'cancelled' as const }
|
||||
: r
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
setError('Failed to cancel reservation');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const pendingReservations = reservations.filter(r => r.status === 'pending');
|
||||
const confirmedReservations = reservations.filter(r => r.status === 'confirmed');
|
||||
const cancelledReservations = reservations.filter(r => r.status === 'cancelled');
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading admin dashboard...</div>;
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-dashboard">
|
||||
<div className="page-header">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<p>Manage resource reservations</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<h3>{pendingReservations.length}</h3>
|
||||
<p>Pending Reservations</p>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>{confirmedReservations.length}</h3>
|
||||
<p>Confirmed Reservations</p>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>{cancelledReservations.length}</h3>
|
||||
<p>Cancelled Reservations</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="reservations-sections">
|
||||
{pendingReservations.length > 0 && (
|
||||
<section className="reservations-section">
|
||||
<h2>Pending Reservations</h2>
|
||||
<div className="reservations-list">
|
||||
{pendingReservations.map(reservation => (
|
||||
<ReservationCard
|
||||
key={reservation.id}
|
||||
reservation={reservation}
|
||||
onConfirm={() => handleConfirmReservation(reservation.id)}
|
||||
onCancel={() => handleCancelReservation(reservation.id)}
|
||||
processing={processing === reservation.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="reservations-section">
|
||||
<h2>All Reservations</h2>
|
||||
<div className="reservations-list">
|
||||
{reservations.length === 0 ? (
|
||||
<p className="no-reservations">No reservations found</p>
|
||||
) : (
|
||||
reservations.map(reservation => (
|
||||
<ReservationCard
|
||||
key={reservation.id}
|
||||
reservation={reservation}
|
||||
onConfirm={reservation.status === 'pending' ? () => handleConfirmReservation(reservation.id) : undefined}
|
||||
onCancel={reservation.status === 'pending' ? () => handleCancelReservation(reservation.id) : undefined}
|
||||
processing={processing === reservation.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ReservationCardProps {
|
||||
reservation: Reservation;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
processing: boolean;
|
||||
}
|
||||
|
||||
const ReservationCard: React.FC<ReservationCardProps> = ({
|
||||
reservation,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
processing
|
||||
}) => {
|
||||
const statusColors = {
|
||||
pending: '#f39c12',
|
||||
confirmed: '#27ae60',
|
||||
cancelled: '#e74c3c'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="reservation-card">
|
||||
<div className="reservation-header">
|
||||
<h3>{reservation.title}</h3>
|
||||
<span
|
||||
className="status-badge"
|
||||
style={{ backgroundColor: statusColors[reservation.status] }}
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h2>Admin Dashboard</h2>
|
||||
<Link
|
||||
to="/resources"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
{reservation.status}
|
||||
</span>
|
||||
Manage Resources
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="reservation-details">
|
||||
<div className="detail-row">
|
||||
<strong>Resource:</strong> {reservation.resource.name}
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<strong>Location:</strong> {reservation.resource.location}
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<strong>Date:</strong> {format(new Date(reservation.startTime), 'MMMM d, yyyy')}
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<strong>Time:</strong> {format(new Date(reservation.startTime), 'h:mm a')} - {format(new Date(reservation.endTime), 'h:mm a')}
|
||||
</div>
|
||||
{reservation.description && (
|
||||
<div className="detail-row">
|
||||
<strong>Description:</strong> {reservation.description}
|
||||
{/* Quick Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd' }}>
|
||||
<h3>Total Reservations</h3>
|
||||
<div style={{ fontSize: '2rem', color: '#2c3e50', marginBottom: '0.5rem' }}>
|
||||
{reservations.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd' }}>
|
||||
<h3>Pending Approvals</h3>
|
||||
<div style={{ fontSize: '2rem', color: '#f39c12', marginBottom: '0.5rem' }}>
|
||||
{pendingReservations.length}
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-row">
|
||||
<strong>Created:</strong> {format(new Date(reservation.createdAt), 'MMM d, yyyy h:mm a')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(onConfirm || onCancel) && (
|
||||
<div className="reservation-actions">
|
||||
{onConfirm && (
|
||||
<button
|
||||
className="btn btn-confirm"
|
||||
onClick={onConfirm}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? 'Processing...' : 'Confirm'}
|
||||
</button>
|
||||
)}
|
||||
{onCancel && (
|
||||
<button
|
||||
className="btn btn-cancel"
|
||||
onClick={onCancel}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? 'Processing...' : 'Cancel'}
|
||||
</button>
|
||||
)}
|
||||
{/* Pending Approvals */}
|
||||
{pendingReservations.length > 0 && (
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<h3>Pending Approval ({pendingReservations.length})</h3>
|
||||
<div style={{ backgroundColor: '#fff3cd', padding: '1rem', borderRadius: '8px', border: '1px solid #ffc107', marginBottom: '1rem' }}>
|
||||
You have {pendingReservations.length} pending reservations that need approval
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<h3>Recent Activity</h3>
|
||||
<div style={{ fontSize: '1rem', color: '#555', marginBottom: '0.5rem' }}>
|
||||
No recent activity
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../services/api';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Resource, CreateReservationRequest } from '../types';
|
||||
import './CreateReservation.css';
|
||||
import { SimpleLibreBookingClient } from '../services/librebooking-api';
|
||||
|
||||
const CreateReservation: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const preselectedResourceId = searchParams.get('resourceId');
|
||||
|
||||
const [formData, setFormData] = useState<CreateReservationRequest>({
|
||||
resourceId: preselectedResourceId || '',
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
const [formData, setFormData] = useState({
|
||||
resourceId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
startTime: '',
|
||||
endTime: ''
|
||||
});
|
||||
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const apiClient = new SimpleLibreBookingClient();
|
||||
|
||||
useEffect(() => {
|
||||
const loadResources = async () => {
|
||||
try {
|
||||
const resourcesData = await apiClient.getResources();
|
||||
setResources(resourcesData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load resources');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadResources();
|
||||
}, []);
|
||||
|
||||
const loadResources = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getResources();
|
||||
const availableResources = data.filter(r => r.isAvailable);
|
||||
setResources(availableResources);
|
||||
|
||||
// If no preselected resource but we have available ones, select the first
|
||||
if (!preselectedResourceId && availableResources.length > 0) {
|
||||
setFormData(prev => ({ ...prev, resourceId: availableResources[0].id }));
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load resources');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = event.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validateForm = (): string | null => {
|
||||
if (!formData.resourceId) return 'Please select a resource';
|
||||
if (!formData.title.trim()) return 'Please enter a title';
|
||||
if (!formData.date) return 'Please select a date';
|
||||
if (!formData.startTime) return 'Please select a start time';
|
||||
if (!formData.endTime) return 'Please select an end time';
|
||||
if (formData.startTime >= formData.endTime) return 'End time must be after start time';
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!formData.resourceId || !formData.title) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.startTime >= formData.endTime) {
|
||||
setError('End time must be after start time');
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const requestData: CreateReservationRequest = {
|
||||
resourceId: formData.resourceId,
|
||||
startDateTime: `${formData.date}T${formData.startTime}`,
|
||||
endDateTime: `${formData.date}T${formData.endTime}`,
|
||||
title: formData.title.trim(),
|
||||
description: formData.description.trim() || '',
|
||||
userId: apiClient.getUserId() || undefined,
|
||||
allowParticipation: true,
|
||||
termsAccepted: true
|
||||
};
|
||||
|
||||
console.log('Creating reservation with data:', requestData);
|
||||
const reservation = await apiClient.createReservation(requestData);
|
||||
|
||||
await api.createReservation(formData);
|
||||
|
||||
// Navigate to resources list on success
|
||||
navigate('/resources', {
|
||||
state: { message: 'Reservation created successfully!' }
|
||||
navigate(`/resources/${formData.resourceId}/calendar`, {
|
||||
state: { newReservation: reservation }
|
||||
});
|
||||
} catch (err) {
|
||||
setError('Failed to create reservation');
|
||||
setError(err instanceof Error ? err.message : 'Failed to create reservation');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof CreateReservationRequest, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
const selectedResource = resources.find(r => r.id === formData.resourceId);
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading resources...</div>;
|
||||
}
|
||||
|
||||
if (resources.length === 0) {
|
||||
return (
|
||||
<div className="no-resources">
|
||||
<h2>No Available Resources</h2>
|
||||
<p>There are currently no available resources to book.</p>
|
||||
</div>
|
||||
);
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="create-reservation">
|
||||
<div className="page-header">
|
||||
<h1>Create New Reservation</h1>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h2>Create Reservation</h2>
|
||||
<Link
|
||||
to="/resources"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{error && (
|
||||
<div style={{
|
||||
backgroundColor: '#f8d7da',
|
||||
color: '#721c24',
|
||||
padding: '1rem',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '1.5rem',
|
||||
border: '1px solid #f5c6cb'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="reservation-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="resourceId">Resource *</label>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3>Create New Reservation</h3>
|
||||
</div>
|
||||
|
||||
{/* Resource Selection */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label htmlFor="resourceId" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||
Resource *
|
||||
</label>
|
||||
<select
|
||||
id="resourceId"
|
||||
name="resourceId"
|
||||
value={formData.resourceId}
|
||||
onChange={(e) => handleInputChange('resourceId', e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem'
|
||||
}}
|
||||
>
|
||||
<option value="">Select a resource</option>
|
||||
<option value="">Select a resource...</option>
|
||||
{resources.map(resource => (
|
||||
<option key={resource.id} value={resource.id}>
|
||||
{resource.name} - {resource.location}
|
||||
{resource.name} ({resource.capacity ? `Capacity: ${resource.capacity}` : 'No capacity limit'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="startTime">Start Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="startTime"
|
||||
value={formData.startTime.toISOString().slice(0, 16)}
|
||||
onChange={(e) => handleInputChange('startTime', new Date(e.target.value))}
|
||||
required
|
||||
/>
|
||||
{/* Resource Details */}
|
||||
{selectedResource && (
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e9ecef',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 0.5rem 0', color: '#2c3e50' }}>
|
||||
{selectedResource.name}
|
||||
</h4>
|
||||
{selectedResource.description && (
|
||||
<p style={{ color: '#666', marginBottom: '0.5rem' }}>
|
||||
{selectedResource.description}
|
||||
</p>
|
||||
)}
|
||||
{selectedResource.location && (
|
||||
<div style={{ fontSize: '0.875rem', color: '#555', marginBottom: '0.5rem' }}>
|
||||
<strong>Location:</strong> {selectedResource.location}
|
||||
</div>
|
||||
)}
|
||||
{selectedResource.capacity && (
|
||||
<div style={{ fontSize: '0.875rem', color: '#555', marginBottom: '0.5rem' }}>
|
||||
<strong>Capacity:</strong> {selectedResource.capacity} people
|
||||
</div>
|
||||
)}
|
||||
{selectedResource.amenities && selectedResource.amenities.length > 0 && (
|
||||
<div style={{ fontSize: '0.875rem', color: '#555', marginBottom: '0.5rem' }}>
|
||||
<strong>Amenities:</strong> {selectedResource.amenities.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="endTime">End Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="endTime"
|
||||
value={formData.endTime.toISOString().slice(0, 16)}
|
||||
onChange={(e) => handleInputChange('endTime', new Date(e.target.value))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="title">Title *</label>
|
||||
{/* Title */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label htmlFor="title" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||
placeholder="Meeting title or purpose"
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
placeholder="e.g., Team Meeting, Client Presentation"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="description">Description</label>
|
||||
{/* Description */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label htmlFor="description" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
placeholder="Additional details about this reservation"
|
||||
rows={4}
|
||||
onChange={handleInputChange}
|
||||
rows={3}
|
||||
placeholder="Optional: Add any additional details about this reservation"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate('/resources')}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/* Date Selection */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label htmlFor="date" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||
Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
value={formData.date}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time Selection */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label htmlFor="startTime" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||
Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
id="startTime"
|
||||
name="startTime"
|
||||
value={formData.startTime}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
min="08:00"
|
||||
max="20:00"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label htmlFor="endTime" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||
End Time *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
id="endTime"
|
||||
name="endTime"
|
||||
value={formData.endTime}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
min="08:00"
|
||||
max="20:00"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '1rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={submitting}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: submitting ? '#95a5a6' : '#27ae60',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
fontSize: '1rem'
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create Reservation'}
|
||||
</button>
|
||||
|
||||
@ -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 { useParams, Link } from 'react-router-dom';
|
||||
import { format, startOfWeek, addDays, startOfDay, addHours, isSameDay, isWithinInterval } from 'date-fns';
|
||||
import { api } from '../services/api';
|
||||
import { Resource, Reservation } from '../types';
|
||||
import './ResourceCalendar.css';
|
||||
import { SimpleLibreBookingClient } from '../services/librebooking-api';
|
||||
import { format, startOfWeek, addDays, isSameDay, isToday, addHours, setMinutes, setHours } from 'date-fns';
|
||||
|
||||
const ResourceCalendar: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@ -14,178 +13,248 @@ const ResourceCalendar: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, currentWeek]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!id) {
|
||||
setError('Invalid resource ID');
|
||||
return;
|
||||
}
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [resourceData, reservationsData] = await Promise.all([
|
||||
api.getResource(id),
|
||||
api.getReservations(id)
|
||||
]);
|
||||
|
||||
if (!resourceData) {
|
||||
setError('Resource not found');
|
||||
return;
|
||||
const api = new SimpleLibreBookingClient();
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [resourceData, reservationsData] = await Promise.all([
|
||||
api.getResource(id),
|
||||
api.getReservations(id)
|
||||
]);
|
||||
setResource(resourceData);
|
||||
setReservations(reservationsData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
console.log('Calendar - Resource:', resourceData);
|
||||
console.log('Calendar - Reservations:', reservationsData);
|
||||
|
||||
setResource(resourceData);
|
||||
setReservations(Array.isArray(reservationsData) ? reservationsData : []);
|
||||
} catch (err) {
|
||||
console.error('Calendar load error:', err);
|
||||
setError('Failed to load resource calendar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const weekStart = startOfWeek(currentWeek, { weekStartsOn: 1 }); // Start on Monday
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
const weekStart = startOfWeek(currentWeek, { weekStartsOn: 1 });
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||
|
||||
// Generate time slots from 8 AM to 8 PM
|
||||
const timeSlots = Array.from({ length: 13 }, (_, i) => addHours(startOfDay(weekStart), 8 + i));
|
||||
const timeSlots = Array.from({ length: 13 }, (_, i) => {
|
||||
const hour = i + 8; // Start at 8 AM
|
||||
return setMinutes(setHours(new Date(), hour), 0);
|
||||
});
|
||||
|
||||
const getReservationsForSlot = (date: Date, timeSlot: Date): Reservation[] => {
|
||||
if (!date || !timeSlot) return [];
|
||||
const getReservationForSlot = (date: Date, timeSlot: Date): Reservation | null => {
|
||||
const slotStart = setHours(setMinutes(new Date(date), timeSlot.getHours()), timeSlot.getMinutes());
|
||||
const slotEnd = addHours(slotStart, 1);
|
||||
|
||||
const slotStart = timeSlot;
|
||||
const slotEnd = addHours(timeSlot, 1);
|
||||
|
||||
const filtered = reservations.filter(reservation => {
|
||||
if (!reservation?.startTime) return false;
|
||||
return reservations.find(reservation => {
|
||||
const reservationStart = new Date(reservation.startTime);
|
||||
const reservationEnd = new Date(reservation.endTime);
|
||||
|
||||
const reservationDate = new Date(reservation.startTime);
|
||||
if (isNaN(reservationDate.getTime())) return false;
|
||||
|
||||
const matches = isSameDay(reservationDate, date) &&
|
||||
isWithinInterval(reservationDate, { start: slotStart, end: slotEnd });
|
||||
|
||||
if (matches) {
|
||||
console.log('Found reservation for slot:', {
|
||||
title: reservation.title,
|
||||
date: reservationDate,
|
||||
slotStart,
|
||||
slotEnd,
|
||||
matches
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
return (
|
||||
(reservationStart < slotEnd && reservationEnd > slotStart) ||
|
||||
(isSameDay(reservationStart, date) &&
|
||||
reservationStart.getHours() === slotStart.getHours())
|
||||
);
|
||||
}) || null;
|
||||
};
|
||||
|
||||
const navigateWeek = (direction: 'prev' | 'next') => {
|
||||
setCurrentWeek(prev => addDays(prev, direction === 'next' ? 7 : -7));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentWeek(new Date());
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading calendar...</div>;
|
||||
return (
|
||||
<div>
|
||||
<h2>Resource Calendar</h2>
|
||||
<div>Loading calendar...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="error">{error}</div>;
|
||||
return (
|
||||
<div>
|
||||
<h2>Resource Calendar</h2>
|
||||
<div style={{ color: '#e74c3c' }}>Error: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource) {
|
||||
return <div className="error">Resource not found</div>;
|
||||
return (
|
||||
<div>
|
||||
<h2>Resource Calendar</h2>
|
||||
<div>Resource not found.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="resource-calendar">
|
||||
<div className="calendar-header">
|
||||
<div className="header-info">
|
||||
<h1>{resource.name} - Calendar</h1>
|
||||
<p className="resource-details">
|
||||
{resource.location} • Capacity: {resource.capacity}
|
||||
</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className="week-navigation">
|
||||
<button onClick={() => navigateWeek('prev')} className="btn btn-secondary">
|
||||
← Previous
|
||||
</button>
|
||||
<button onClick={goToToday} className="btn btn-secondary">
|
||||
Today
|
||||
</button>
|
||||
<span className="current-week">
|
||||
{format(weekStart, 'MMM d')} - {format(addDays(weekStart, 6), 'MMM d, yyyy')}
|
||||
</span>
|
||||
<button onClick={() => navigateWeek('next')} className="btn btn-secondary">
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
<Link to="/reservations/new" className="btn btn-primary">
|
||||
Book Resource
|
||||
</Link>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<div>
|
||||
<h2>{resource.name} - Calendar</h2>
|
||||
{resource.description && (
|
||||
<p style={{ color: '#666', margin: '0.5rem 0' }}>{resource.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/reservations/new?resourceId=${resource.id}`}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: '#27ae60',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Book This Resource
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="calendar-container">
|
||||
<div className="calendar-grid">
|
||||
{/* Time column header */}
|
||||
<div className="time-header"></div>
|
||||
|
||||
{/* Day headers */}
|
||||
{weekDays.map(day => (
|
||||
<div key={day.toString()} className="day-header">
|
||||
<div className="day-name">{format(day, 'EEE')}</div>
|
||||
<div className="day-date">{format(day, 'd')}</div>
|
||||
{/* Week Navigation */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', padding: '1rem', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<button
|
||||
onClick={() => navigateWeek('prev')}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
← Previous Week
|
||||
</button>
|
||||
<h3 style={{ margin: 0, color: '#2c3e50' }}>
|
||||
{format(weekStart, 'MMM d')} - {format(addDays(weekStart, 6), 'MMM d, yyyy')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => navigateWeek('next')}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Next Week →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'white'
|
||||
}}>
|
||||
{/* Header Row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '100px repeat(7, 1fr)', backgroundColor: '#2c3e50', color: 'white' }}>
|
||||
<div style={{ padding: '1rem', textAlign: 'center', fontWeight: 'bold', borderRight: '1px solid #ddd' }}>
|
||||
Time
|
||||
</div>
|
||||
{weekDays.map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
borderRight: index < 6 ? '1px solid #ddd' : 'none',
|
||||
backgroundColor: isToday(day) ? '#34495e' : 'transparent'
|
||||
}}
|
||||
>
|
||||
<div>{format(day, 'EEEE')}</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>
|
||||
{format(day, 'MMM d')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
{timeSlots.map(timeSlot => (
|
||||
<React.Fragment key={timeSlot.toString()}>
|
||||
<div className="time-label">
|
||||
{format(timeSlot, 'h:mm a')}
|
||||
</div>
|
||||
{/* Time Slots */}
|
||||
{timeSlots.map((timeSlot, timeIndex) => (
|
||||
<div key={timeIndex} style={{ display: 'grid', gridTemplateColumns: '100px repeat(7, 1fr)' }}>
|
||||
{/* Time Column */}
|
||||
<div style={{
|
||||
padding: '0.75rem',
|
||||
textAlign: 'center',
|
||||
borderRight: '1px solid #ddd',
|
||||
borderBottom: '1px solid #ddd',
|
||||
backgroundColor: '#f8f9fa',
|
||||
fontSize: '0.875rem'
|
||||
}}>
|
||||
{format(timeSlot, 'h a')}
|
||||
</div>
|
||||
|
||||
{/* Day Columns */}
|
||||
{weekDays.map((day, dayIndex) => {
|
||||
const reservation = getReservationForSlot(day, timeSlot);
|
||||
|
||||
{weekDays.map(day => {
|
||||
const slotReservations = getReservationsForSlot(day, timeSlot);
|
||||
const isToday = isSameDay(day, new Date());
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${day.toString()}-${timeSlot.toString()}`}
|
||||
className={`time-slot ${isToday ? 'today' : ''}`}
|
||||
>
|
||||
{slotReservations.map(reservation => (
|
||||
<div
|
||||
key={reservation.id}
|
||||
className={`reservation-item ${reservation.status}`}
|
||||
>
|
||||
<div className="reservation-title">{reservation.title}</div>
|
||||
<div className="reservation-time">
|
||||
{format(new Date(reservation.startTime), 'h:mm a')} -
|
||||
{format(new Date(reservation.endTime), 'h:mm a')}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={dayIndex}
|
||||
style={{
|
||||
padding: '0.25rem',
|
||||
borderRight: dayIndex < 6 ? '1px solid #ddd' : 'none',
|
||||
borderBottom: '1px solid #ddd',
|
||||
backgroundColor: reservation
|
||||
? reservation.status === 'confirmed' ? '#d4edda'
|
||||
: reservation.status === 'cancelled' ? '#f8d7da'
|
||||
: '#fff3cd'
|
||||
: isToday(day) ? '#f8f9fa' : 'white',
|
||||
fontSize: '0.75rem',
|
||||
minHeight: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{reservation && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: reservation.status === 'cancelled' ? '#721c24' : '#333',
|
||||
fontWeight: '500',
|
||||
padding: '0.25rem'
|
||||
}}>
|
||||
<div style={{ fontWeight: 'bold' }}>{reservation.title}</div>
|
||||
<div style={{ fontSize: '0.625rem', opacity: 0.8 }}>
|
||||
{format(new Date(reservation.startTime), 'h:mm')} - {format(new Date(reservation.endTime), 'h:mm')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ marginTop: '1.5rem', padding: '1rem', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<h4 style={{ margin: '0 0 0.5rem 0' }}>Legend</h4>
|
||||
<div style={{ display: 'flex', gap: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '16px', height: '16px', backgroundColor: '#d4edda', border: '1px solid #c3e6cb' }}></div>
|
||||
<span>Confirmed</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '16px', height: '16px', backgroundColor: '#fff3cd', border: '1px solid #ffeaa7' }}></div>
|
||||
<span>Pending</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '16px', height: '16px', backgroundColor: '#f8d7da', border: '1px solid #f5c6cb' }}></div>
|
||||
<span>Cancelled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 { Link } from 'react-router-dom';
|
||||
import { api } from '../services/api';
|
||||
import { Resource } from '../types';
|
||||
import './ResourcesList.css';
|
||||
import { SimpleLibreBookingClient } from '../services/librebooking-api';
|
||||
|
||||
const ResourcesList: React.FC = () => {
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
@ -10,102 +9,174 @@ const ResourcesList: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const api = new SimpleLibreBookingClient();
|
||||
|
||||
const loadResources = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const resourcesData = await api.getResources();
|
||||
console.log('Resources loaded:', resourcesData);
|
||||
console.log('Resources count:', resourcesData.length);
|
||||
setResources(resourcesData);
|
||||
} catch (err) {
|
||||
console.error('Error loading resources:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load resources');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadResources();
|
||||
}, []);
|
||||
|
||||
const loadResources = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getResources();
|
||||
setResources(data);
|
||||
} catch (err) {
|
||||
setError('Failed to load resources');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading resources...</div>;
|
||||
return (
|
||||
<div>
|
||||
<h2>Resources</h2>
|
||||
<div>Loading resources...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="error">{error}</div>;
|
||||
return (
|
||||
<div>
|
||||
<h2>Resources</h2>
|
||||
<div style={{ color: '#e74c3c' }}>Error: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="resources-list">
|
||||
<div className="page-header">
|
||||
<h1>Available Resources</h1>
|
||||
<Link to="/reservations/new" className="btn btn-primary">
|
||||
New Reservation
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h2>Available Resources</h2>
|
||||
<Link
|
||||
to="/reservations/new"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
New Booking
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="resources-grid">
|
||||
{resources.map(resource => (
|
||||
<div key={resource.id} className="resource-card">
|
||||
<div className="resource-image">
|
||||
{resource.imageUrl ? (
|
||||
<img src={resource.imageUrl} alt={resource.name} />
|
||||
) : (
|
||||
<div className="placeholder-image">🏢</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="resource-content">
|
||||
<h3>{resource.name}</h3>
|
||||
<p className="resource-description">{resource.description}</p>
|
||||
|
||||
{resource.location && (
|
||||
<p className="resource-location">📍 {resource.location}</p>
|
||||
)}
|
||||
|
||||
{resource.capacity && (
|
||||
<p className="resource-capacity">👥 Capacity: {resource.capacity}</p>
|
||||
)}
|
||||
|
||||
{resource.amenities && resource.amenities.length > 0 && (
|
||||
<div className="resource-amenities">
|
||||
<strong>Amenities:</strong>
|
||||
<ul>
|
||||
{resource.amenities.map((amenity, index) => (
|
||||
<li key={index}>{amenity}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="resource-status">
|
||||
<span className={`status-badge ${resource.isAvailable ? 'available' : 'unavailable'}`}>
|
||||
|
||||
{resources.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<p>No resources available at the moment.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1.5rem' }}>
|
||||
{resources.map((resource) => (
|
||||
<div
|
||||
key={resource.id}
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '1.5rem',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
|
||||
<h3 style={{ margin: 0, color: '#2c3e50' }}>{resource.name}</h3>
|
||||
<span
|
||||
style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
backgroundColor: resource.isAvailable ? '#d4edda' : '#f8d7da',
|
||||
color: resource.isAvailable ? '#155724' : '#721c24'
|
||||
}}
|
||||
>
|
||||
{resource.isAvailable ? 'Available' : 'Unavailable'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="resource-actions">
|
||||
{resource.description && (
|
||||
<p style={{ color: '#666', marginBottom: '1rem', lineHeight: '1.5' }}>
|
||||
{resource.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{resource.location && (
|
||||
<div style={{ marginBottom: '0.5rem', color: '#555' }}>
|
||||
<strong>Location:</strong> {resource.location}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resource.capacity && (
|
||||
<div style={{ marginBottom: '0.5rem', color: '#555' }}>
|
||||
<strong>Capacity:</strong> {resource.capacity} people
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resource.amenities && resource.amenities.length > 0 && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<strong>Amenities:</strong>
|
||||
<div style={{ marginTop: '0.5rem', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{resource.amenities.map((amenity, index) => (
|
||||
<span
|
||||
key={index}
|
||||
style={{
|
||||
backgroundColor: '#e9ecef',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
{amenity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
||||
<Link
|
||||
to={`/resources/${resource.id}/calendar`}
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'inline-block',
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
View Calendar
|
||||
</Link>
|
||||
{resource.isAvailable ? (
|
||||
<Link
|
||||
to={`/reservations/new?resourceId=${resource.id}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Book Now
|
||||
</Link>
|
||||
) : (
|
||||
<span className="btn btn-primary disabled">
|
||||
Book Now
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to={`/reservations/new?resourceId=${resource.id}`}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'inline-block',
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#27ae60',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
Book Now
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 { Link } from 'react-router-dom';
|
||||
import { format, startOfDay, isAfter, isBefore } from 'date-fns';
|
||||
import { api } from '../services/api';
|
||||
import { Reservation } from '../types';
|
||||
import './UserDashboard.css';
|
||||
import { SimpleLibreBookingClient } from '../services/librebooking-api';
|
||||
import { format, isToday, isTomorrow } from 'date-fns';
|
||||
|
||||
const UserDashboard: React.FC = () => {
|
||||
const [reservations, setReservations] = useState<Reservation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const api = new SimpleLibreBookingClient();
|
||||
|
||||
const loadReservations = async () => {
|
||||
try {
|
||||
const reservationsData = await api.getReservations();
|
||||
setReservations(reservationsData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load reservations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadReservations();
|
||||
}, []);
|
||||
|
||||
const loadReservations = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getReservations();
|
||||
// Sort by start date, upcoming first
|
||||
const sortedData = data.sort((a, b) =>
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
|
||||
);
|
||||
setReservations(sortedData);
|
||||
} catch (err) {
|
||||
setError('Failed to load reservations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Group reservations by status
|
||||
const pendingReservations = reservations.filter(r => r.status === 'pending');
|
||||
const confirmedReservations = reservations.filter(r => r.status === 'confirmed');
|
||||
|
||||
// Group upcoming reservations
|
||||
const upcomingReservations = reservations.filter(r =>
|
||||
r.status !== 'cancelled' && new Date(r.endTime) > new Date()
|
||||
);
|
||||
|
||||
const todayReservations = reservations.filter(r =>
|
||||
r.status !== 'cancelled' && (
|
||||
isToday(new Date(r.startTime)) ||
|
||||
isToday(new Date(r.endTime))
|
||||
)
|
||||
);
|
||||
|
||||
const getReservationLabel = (reservation: Reservation): string => {
|
||||
const startDate = new Date(reservation.startTime);
|
||||
const endDate = new Date(reservation.endTime);
|
||||
|
||||
if (isToday(startDate)) {
|
||||
return `Today, ${format(startDate, 'h:mm a')} - ${format(endDate, 'h:mm a')}`;
|
||||
} else if (isTomorrow(startDate)) {
|
||||
return `Tomorrow, ${format(startDate, 'h:mm a')} - ${format(endDate, 'h:mm a')}`;
|
||||
} else {
|
||||
return format(startDate, 'MMM d, h:mm a') + ' - ' + format(endDate, 'h:mm a');
|
||||
}
|
||||
};
|
||||
|
||||
const filterReservations = (reservations: Reservation[]) => {
|
||||
const now = new Date();
|
||||
const today = startOfDay(now);
|
||||
|
||||
switch (filter) {
|
||||
case 'upcoming':
|
||||
return reservations.filter(r =>
|
||||
isAfter(new Date(r.startTime), today) ||
|
||||
isSameDay(new Date(r.startTime), today)
|
||||
);
|
||||
case 'past':
|
||||
return reservations.filter(r =>
|
||||
isBefore(new Date(r.startTime), today)
|
||||
);
|
||||
default:
|
||||
return reservations;
|
||||
}
|
||||
};
|
||||
|
||||
const filterByDate = (reservations: Reservation[]) => {
|
||||
if (!selectedDate) return reservations;
|
||||
return reservations.filter(r =>
|
||||
isSameDay(new Date(r.startTime), selectedDate)
|
||||
);
|
||||
};
|
||||
|
||||
const getFilteredReservations = () => {
|
||||
let filtered = filterReservations(reservations);
|
||||
filtered = filterByDate(filtered);
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const getStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'confirmed':
|
||||
return '#27ae60';
|
||||
@ -77,198 +68,236 @@ const UserDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isSameDay = (date1: Date, date2: Date) => {
|
||||
return format(date1, 'yyyy-MM-dd') === format(date2, 'yyyy-MM-dd');
|
||||
};
|
||||
|
||||
const clearDateFilter = () => {
|
||||
setSelectedDate(null);
|
||||
};
|
||||
|
||||
const upcomingReservations = filterReservations(reservations).filter(r =>
|
||||
isAfter(new Date(r.startTime), new Date()) ||
|
||||
isSameDay(new Date(r.startTime), new Date())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading reservations...</div>;
|
||||
return (
|
||||
<div>
|
||||
<h2>My Reservations</h2>
|
||||
<div>Loading your reservations...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredReservations = getFilteredReservations();
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h2>My Reservations</h2>
|
||||
<div style={{ color: '#e74c3c' }}>Error: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-dashboard">
|
||||
<div className="page-header">
|
||||
<div className="header-content">
|
||||
<h1>My Reservations</h1>
|
||||
<p>View and manage all your resource bookings</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<Link to="/reservations/new" className="btn btn-primary">
|
||||
New Reservation
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h2>My Reservations</h2>
|
||||
<Link
|
||||
to="/reservations/new"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: '#27ae60',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
New Booking
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<h3>{upcomingReservations.length}</h3>
|
||||
<p>Upcoming Reservations</p>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>{upcomingReservations.filter(r => r.status === 'confirmed').length}</h3>
|
||||
<p>Confirmed Bookings</p>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<h3>{upcomingReservations.filter(r => r.status === 'pending').length}</h3>
|
||||
<p>Pending Approval</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="filters-section">
|
||||
<div className="filter-tabs">
|
||||
<button
|
||||
className={`filter-tab ${filter === 'upcoming' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('upcoming')}
|
||||
>
|
||||
Upcoming
|
||||
</button>
|
||||
<button
|
||||
className={`filter-tab ${filter === 'past' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('past')}
|
||||
>
|
||||
Past
|
||||
</button>
|
||||
<button
|
||||
className={`filter-tab ${filter === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#3498db', marginBottom: '0.5rem' }}>
|
||||
{upcomingReservations.length}
|
||||
</div>
|
||||
<div style={{ color: '#666' }}>Upcoming Bookings</div>
|
||||
</div>
|
||||
|
||||
<div className="date-filter">
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate ? format(selectedDate, 'yyyy-MM-dd') : ''}
|
||||
onChange={(e) => setSelectedDate(e.target.value ? new Date(e.target.value) : null)}
|
||||
className="date-input"
|
||||
/>
|
||||
{selectedDate && (
|
||||
<button onClick={clearDateFilter} className="clear-date-btn">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#f39c12', marginBottom: '0.5rem' }}>
|
||||
{pendingReservations.length}
|
||||
</div>
|
||||
<div style={{ color: '#666' }}>Pending Confirmation</div>
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: 'white', padding: '1.5rem', borderRadius: '8px', border: '1px solid #ddd', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#27ae60', marginBottom: '0.5rem' }}>
|
||||
{confirmedReservations.length}
|
||||
</div>
|
||||
<div style={{ color: '#666' }}>Confirmed Bookings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reservations List */}
|
||||
<div className="reservations-section">
|
||||
<div className="section-header">
|
||||
<h2>
|
||||
{filter === 'upcoming' && 'Upcoming Reservations'}
|
||||
{filter === 'past' && 'Past Reservations'}
|
||||
{filter === 'all' && 'All Reservations'}
|
||||
{selectedDate && ` - ${format(selectedDate, 'MMM d, yyyy')}`}
|
||||
</h2>
|
||||
<span className="result-count">
|
||||
{filteredReservations.length} {filteredReservations.length === 1 ? 'reservation' : 'reservations'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{filteredReservations.length === 0 ? (
|
||||
<div className="no-reservations">
|
||||
<div className="no-reservations-icon">📅</div>
|
||||
<h3>No reservations found</h3>
|
||||
<p>
|
||||
{selectedDate
|
||||
? `No reservations found for ${format(selectedDate, 'MMMM d, yyyy')}`
|
||||
: filter === 'upcoming'
|
||||
? 'You have no upcoming reservations'
|
||||
: filter === 'past'
|
||||
? 'You have no past reservations'
|
||||
: 'No reservations found'
|
||||
}
|
||||
</p>
|
||||
{!selectedDate && filter === 'upcoming' && (
|
||||
<Link to="/reservations/new" className="btn btn-primary">
|
||||
Create Your First Reservation
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="reservations-list">
|
||||
{filteredReservations.map(reservation => (
|
||||
<div key={reservation.id} className="reservation-card">
|
||||
<div className="reservation-header">
|
||||
<div className="reservation-info">
|
||||
<h3>{reservation.title}</h3>
|
||||
<p className="resource-name">{reservation.resource.name}</p>
|
||||
</div>
|
||||
<div className="reservation-status">
|
||||
<span
|
||||
className="status-badge"
|
||||
style={{ backgroundColor: getStatusColor(reservation.status) }}
|
||||
>
|
||||
{reservation.status}
|
||||
</span>
|
||||
{/* Today's Reservations */}
|
||||
{todayReservations.length > 0 && (
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<h3 style={{ color: '#2c3e50', marginBottom: '1rem' }}>Today's Reservations</h3>
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{todayReservations.map(reservation => (
|
||||
<div
|
||||
key={reservation.id}
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderLeft: `4px solid ${getStatusColor(reservation.status)}`
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '0.5rem' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 0.25rem 0', color: '#2c3e50' }}>
|
||||
{reservation.title}
|
||||
</h4>
|
||||
<div style={{ color: '#666', fontSize: '0.875rem' }}>
|
||||
{reservation.resource.name}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '500',
|
||||
backgroundColor: getStatusColor(reservation.status) + '20',
|
||||
color: getStatusColor(reservation.status)
|
||||
}}
|
||||
>
|
||||
{reservation.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="reservation-details">
|
||||
<div className="detail-group">
|
||||
<div className="detail-item">
|
||||
<span className="detail-icon">📅</span>
|
||||
<span>{format(new Date(reservation.startTime), 'EEEE, MMMM d, yyyy')}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="detail-icon">🕐</span>
|
||||
<span>
|
||||
{format(new Date(reservation.startTime), 'h:mm a')} -
|
||||
{format(new Date(reservation.endTime), 'h:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-group">
|
||||
<div className="detail-item">
|
||||
<span className="detail-icon">📍</span>
|
||||
<span>{reservation.resource.location}</span>
|
||||
</div>
|
||||
{reservation.resource.capacity && (
|
||||
<div className="detail-item">
|
||||
<span className="detail-icon">👥</span>
|
||||
<span>Capacity: {reservation.resource.capacity}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{reservation.description && (
|
||||
<div className="detail-group">
|
||||
<div className="detail-item description">
|
||||
<span className="detail-icon">📝</span>
|
||||
<span>{reservation.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ color: '#555', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
||||
{getReservationLabel(reservation)}
|
||||
</div>
|
||||
|
||||
<div className="reservation-actions">
|
||||
|
||||
{reservation.description && (
|
||||
<div style={{ color: '#666', fontSize: '0.875rem', fontStyle: 'italic' }}>
|
||||
{reservation.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
|
||||
<Link
|
||||
to={`/resources/${reservation.resourceId}/calendar`}
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
View Calendar
|
||||
</Link>
|
||||
{reservation.status === 'pending' && (
|
||||
<div className="pending-note">
|
||||
⏳ Awaiting approval
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Reservations */}
|
||||
<div>
|
||||
<h3 style={{ color: '#2c3e50', marginBottom: '1rem' }}>All Reservations</h3>
|
||||
|
||||
{reservations.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '2rem',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ddd'
|
||||
}}>
|
||||
<p style={{ color: '#666', marginBottom: '1rem' }}>
|
||||
You don't have any reservations yet.
|
||||
</p>
|
||||
<Link
|
||||
to="/resources"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
Browse Resources
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{reservations
|
||||
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
|
||||
.map(reservation => (
|
||||
<div
|
||||
key={reservation.id}
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderLeft: `4px solid ${getStatusColor(reservation.status)}`
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '0.5rem' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 0.25rem 0', color: '#2c3e50' }}>
|
||||
{reservation.title}
|
||||
</h4>
|
||||
<div style={{ color: '#666', fontSize: '0.875rem' }}>
|
||||
{reservation.resource.name}
|
||||
{reservation.resource.location && ` • ${reservation.resource.location}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '500',
|
||||
backgroundColor: getStatusColor(reservation.status) + '20',
|
||||
color: getStatusColor(reservation.status)
|
||||
}}
|
||||
>
|
||||
{reservation.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ color: '#555', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
||||
{getReservationLabel(reservation)}
|
||||
</div>
|
||||
|
||||
{reservation.description && (
|
||||
<div style={{ color: '#666', fontSize: '0.875rem', fontStyle: 'italic', marginBottom: '0.5rem' }}>
|
||||
{reservation.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ color: '#888', fontSize: '0.75rem' }}>
|
||||
Booked on {format(new Date(reservation.createdAt), 'MMM d, yyyy')}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<Link
|
||||
to={`/resources/${reservation.resourceId}/calendar`}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.875rem'
|
||||
}}
|
||||
>
|
||||
View Calendar
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -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 {
|
||||
baseURL: string;
|
||||
apiKey?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
class LibreBookingAPIClient {
|
||||
private config: LibreBookingAPIConfig;
|
||||
export class SimpleLibreBookingClient {
|
||||
private baseUrl: string;
|
||||
private sessionToken: string | null = null;
|
||||
private userId: string | null = null;
|
||||
|
||||
constructor(config: LibreBookingAPIConfig) {
|
||||
this.config = config;
|
||||
this.baseUrl = config.baseURL.replace(/\/$/, ''); // Remove trailing slash
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl || process.env.REACT_APP_LIBREBOOKING_API_URL || 'http://localhost:8080/Web';
|
||||
}
|
||||
|
||||
private async makeRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
async authenticate(username: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
console.log('Attempting authentication with:', { username, baseUrl: this.baseUrl });
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/Services/index.php/Authentication/Authenticate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
console.log('Authentication response status:', response.status);
|
||||
const data = await response.json();
|
||||
console.log('Authentication response data:', data);
|
||||
|
||||
if (response.ok && data.isAuthenticated) {
|
||||
this.sessionToken = data.sessionToken;
|
||||
this.userId = data.userId?.toString();
|
||||
console.log('Authentication successful:', { sessionToken: this.sessionToken, userId: this.userId });
|
||||
return true;
|
||||
} else {
|
||||
console.log('Authentication failed:', { status: response.status, data });
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getResources(): Promise<Resource[]> {
|
||||
if (!this.sessionToken) {
|
||||
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
|
||||
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
|
||||
|
||||
if (username && password) {
|
||||
const authenticated = await this.authenticate(username, password);
|
||||
if (!authenticated) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.sessionToken && this.userId) {
|
||||
headers['X-Booked-SessionToken'] = this.sessionToken;
|
||||
headers['X-Booked-UserId'] = this.userId;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/Services/index.php/Resources/`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Resources response:', data);
|
||||
return data.resources || [];
|
||||
}
|
||||
|
||||
async getResource(id: string): Promise<Resource | null> {
|
||||
if (!this.sessionToken) {
|
||||
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
|
||||
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
|
||||
|
||||
if (username && password) {
|
||||
const authenticated = await this.authenticate(username, password);
|
||||
if (!authenticated) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.sessionToken && this.userId) {
|
||||
headers['X-Booked-SessionToken'] = this.sessionToken;
|
||||
headers['X-Booked-UserId'] = this.userId;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/Services/index.php/Resources/${id}`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Resource response:', data);
|
||||
return data.resource || null;
|
||||
}
|
||||
|
||||
async getReservations(resourceId?: string): Promise<Reservation[]> {
|
||||
if (!this.sessionToken) {
|
||||
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
|
||||
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
|
||||
|
||||
if (username && password) {
|
||||
const authenticated = await this.authenticate(username, password);
|
||||
if (!authenticated) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const url = resourceId
|
||||
? `${this.baseUrl}/Services/index.php/Reservations/?resourceId=${resourceId}`
|
||||
: `${this.baseUrl}/Services/index.php/Reservations/`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
if (this.config.apiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
|
||||
|
||||
if (this.sessionToken && this.userId) {
|
||||
headers['X-Booked-SessionToken'] = this.sessionToken;
|
||||
headers['X-Booked-UserId'] = this.userId;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(this.config.timeout || 10000),
|
||||
});
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
} else {
|
||||
return response.text() as unknown as T;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Network error: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Resources
|
||||
async getResources(): Promise<Resource[]> {
|
||||
return this.makeRequest<Resource[]>('/api/resources');
|
||||
}
|
||||
|
||||
async getResource(id: string): Promise<Resource> {
|
||||
return this.makeRequest<Resource>(`/api/resources/${id}`);
|
||||
}
|
||||
|
||||
// Reservations
|
||||
async getReservations(resourceId?: string): Promise<Reservation[]> {
|
||||
const params = resourceId ? `?resourceId=${resourceId}` : '';
|
||||
return this.makeRequest<Reservation[]>(`/api/reservations${params}`);
|
||||
}
|
||||
|
||||
async getReservation(id: string): Promise<Reservation> {
|
||||
return this.makeRequest<Reservation>(`/api/reservations/${id}`);
|
||||
const data = await response.json();
|
||||
console.log('Reservations response:', data);
|
||||
return data.reservations || [];
|
||||
}
|
||||
|
||||
async createReservation(data: CreateReservationRequest): Promise<Reservation> {
|
||||
return this.makeRequest<Reservation>('/api/reservations', {
|
||||
if (!this.sessionToken) {
|
||||
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
|
||||
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
|
||||
|
||||
if (username && password) {
|
||||
const authenticated = await this.authenticate(username, password);
|
||||
if (!authenticated) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the exact field names expected by LibreBooking API
|
||||
const requestData = {
|
||||
resourceId: data.resourceId,
|
||||
startDateTime: data.startDateTime,
|
||||
endDateTime: data.endDateTime,
|
||||
title: data.title,
|
||||
description: data.description || '',
|
||||
userId: this.getUserId() || undefined,
|
||||
allowParticipation: true,
|
||||
termsAccepted: true
|
||||
};
|
||||
|
||||
console.log('Creating reservation with data:', requestData);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.sessionToken && this.userId) {
|
||||
headers['X-Booked-SessionToken'] = this.sessionToken;
|
||||
headers['X-Booked-UserId'] = this.userId;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/Services/index.php/Reservations/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers,
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reservation = await response.json();
|
||||
return reservation;
|
||||
}
|
||||
|
||||
async updateReservationStatus(
|
||||
id: string,
|
||||
status: 'confirmed' | 'cancelled'
|
||||
): Promise<Reservation> {
|
||||
return this.makeRequest<Reservation>(`/api/reservations/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
async updateReservationStatus(id: string, status: 'confirmed' | 'cancelled'): Promise<Reservation> {
|
||||
if (!this.sessionToken) {
|
||||
const username = process.env.REACT_APP_LIBREBOOKING_USERNAME;
|
||||
const password = process.env.REACT_APP_LIBREBOOKING_PASSWORD;
|
||||
|
||||
if (username && password) {
|
||||
const authenticated = await this.authenticate(username, password);
|
||||
if (!authenticated) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.sessionToken && this.userId) {
|
||||
headers['X-Booked-SessionToken'] = this.sessionToken;
|
||||
headers['X-Booked-UserId'] = this.userId;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/Services/index.php/Reservations/${id}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reservation = await response.json();
|
||||
return reservation;
|
||||
}
|
||||
|
||||
async cancelReservation(id: string): Promise<Reservation> {
|
||||
return this.makeRequest<Reservation>(`/api/reservations/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
getUserId(): string | null {
|
||||
return this.userId || null;
|
||||
}
|
||||
|
||||
// Users (if needed for admin functions)
|
||||
async getUsers(): Promise<any[]> {
|
||||
return this.makeRequest<any[]>('/api/users');
|
||||
}
|
||||
|
||||
async getUserReservations(userId: string): Promise<Reservation[]> {
|
||||
return this.makeRequest<Reservation[]>(`/api/users/${userId}/reservations`);
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function to create API client with environment variables
|
||||
export function createLibreBookingClient(
|
||||
config?: Partial<LibreBookingAPIConfig>
|
||||
): LibreBookingAPIClient {
|
||||
const defaultConfig: LibreBookingAPIConfig = {
|
||||
baseURL: process.env.REACT_APP_LIBREBOOKING_API_URL || 'http://localhost:8080',
|
||||
apiKey: process.env.REACT_APP_LIBREBOOKING_API_KEY,
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
const finalConfig = { ...defaultConfig, ...config };
|
||||
return new LibreBookingAPIClient(finalConfig);
|
||||
}
|
||||
|
||||
// Example of how to integrate with the existing mock API
|
||||
export function integrateWithMockAPI(mockAPI: any) {
|
||||
return {
|
||||
getResources: mockAPI.getResources,
|
||||
getResource: mockAPI.getResource,
|
||||
getReservations: mockAPI.getReservations,
|
||||
createReservation: mockAPI.createReservation,
|
||||
updateReservationStatus: mockAPI.updateReservationStatus,
|
||||
};
|
||||
}
|
||||
@ -31,8 +31,11 @@ export interface User {
|
||||
|
||||
export interface CreateReservationRequest {
|
||||
resourceId: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
startDateTime: string; // Match field name from LibreBooking API
|
||||
endDateTime: string; // Match field name from LibreBooking API
|
||||
title: string;
|
||||
description?: string;
|
||||
userId?: string; // Allow null initially
|
||||
allowParticipation?: boolean;
|
||||
termsAccepted?: boolean;
|
||||
}
|
||||
37
test-api.js
Normal file
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