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