This commit is contained in:
Joshua Schmucker 2026-01-30 14:32:53 +01:00
parent 941bc7e5e4
commit bf4136b8dc
22 changed files with 3090 additions and 76 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
# Environment variables for LibreBooking API integration
REACT_APP_LIBREBOOKING_API_URL=http://localhost:8080
# Optional: API key if authentication is required
# REACT_APP_LIBREBOOKING_API_KEY=your-api-key-here

142
README.md
View File

@ -1,46 +1,136 @@
# Getting Started with Create React App
# LibreBooking UI
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
A modern, user-friendly web interface for LibreBooking that provides an improved booking experience while maintaining compatibility with the LibreBooking API.
## Available Scripts
## Features
In the project directory, you can run:
### For Users
- **Resource Browsing**: View available resources with detailed information including location, capacity, and amenities
- **Easy Booking**: Intuitive reservation creation with real-time availability checking
- **Calendar View**: Visual weekly calendar showing resource availability and existing bookings
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
### `npm start`
### For Administrators
- **Admin Dashboard**: Centralized management interface for all reservations
- **Reservation Management**: Confirm, cancel, and review booking requests
- **Status Tracking**: Monitor pending, confirmed, and cancelled reservations
- **Quick Actions**: One-click confirmation and cancellation of bookings
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
## Technology Stack
The page will reload if you make edits.\
You will also see any lint errors in the console.
- **Frontend**: React 18 with TypeScript
- **Routing**: React Router for navigation
- **Date Handling**: date-fns for date manipulation
- **Styling**: CSS3 with responsive design
- **API Integration**: Structured client for LibreBooking API compatibility
### `npm test`
## Project Structure
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
```
src/
├── components/ # Reusable UI components
│ ├── Layout.tsx # Main navigation and layout
│ └── Layout.css
├── pages/ # Page components
│ ├── ResourcesList.tsx # Resource listing page
│ ├── CreateReservation.tsx # Booking form
│ ├── ResourceCalendar.tsx # Weekly calendar view
│ └── AdminDashboard.tsx # Admin management interface
├── services/ # API and business logic
│ ├── api.ts # Mock API for development
│ └── librebooking-api.ts # LibreBooking API client
├── types/ # TypeScript interfaces
│ └── index.ts # Core data types
└── App.tsx # Main application component
```
### `npm run build`
## Getting Started
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
### Prerequisites
- Node.js 16+
- npm or yarn
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
### Installation
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
1. Install dependencies:
```bash
npm install
```
### `npm run eject`
2. Start the development server:
```bash
npm start
```
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
3. Open [http://localhost:3000](http://localhost:3000) in your browser
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
## API Integration
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
### Development Mode
The application runs with mock data by default for development and testing.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
### Production Integration
## Learn More
1. Copy the environment template:
```bash
cp .env.example .env
```
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
2. Configure your LibreBooking API endpoint:
```env
REACT_APP_LIBREBOOKING_API_URL=https://your-librebooking-instance.com
```
To learn React, check out the [React documentation](https://reactjs.org/).
3. Update the API client in `src/services/api.ts` to use the real LibreBooking API:
```typescript
import { createLibreBookingClient } from './librebooking-api';
export const api = createLibreBookingClient();
```
## Available Pages
- **/resources** - Browse available resources
- **/resources/:id/calendar** - View resource availability calendar
- **/reservations/new** - Create a new reservation
- **/admin** - Admin dashboard for managing reservations
## Building for Production
```bash
npm run build
```
This creates a `build` folder with the production-ready application.
## Features in Detail
### Resource Management
- List all available resources with filtering options
- Display resource details: location, capacity, amenities
- Real-time availability status
- Quick booking shortcuts
### Reservation System
- Step-by-step booking process
- Date and time selection with validation
- Resource-specific booking forms
- Confirmation and status tracking
### Calendar View
- Weekly calendar layout
- Visual reservation blocks
- Navigation between weeks
- Today highlighting
- Color-coded reservation status
### Admin Dashboard
- Overview statistics
- Pending reservation alerts
- Quick action buttons
- Detailed reservation information
- Status management
## Contributing
This UI is designed to be an independent frontend that communicates with the LibreBooking backend via REST API. The mock data service makes it easy to develop and test without a backend connection.

97
package-lock.json generated
View File

@ -16,8 +16,11 @@
"@types/node": "^16.18.126",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"date-fns": "^4.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
@ -3595,6 +3598,12 @@
"@types/node": "*"
}
},
"node_modules/@types/history": {
"version": "4.7.11",
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
"integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
"license": "MIT"
},
"node_modules/@types/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@ -3731,6 +3740,27 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-router": {
"version": "5.1.20",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
"integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
"license": "MIT",
"dependencies": {
"@types/history": "^4.7.11",
"@types/react": "*"
}
},
"node_modules/@types/react-router-dom": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
"integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
"license": "MIT",
"dependencies": {
"@types/history": "^4.7.11",
"@types/react": "*",
"@types/react-router": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@ -6384,6 +6414,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -13732,6 +13772,57 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/react-scripts": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
@ -14610,6 +14701,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",

View File

@ -11,8 +11,11 @@
"@types/node": "^16.18.126",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"date-fns": "^4.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"

View File

@ -1,38 +1,20 @@
.App {
text-align: center;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f6fa;
color: #2c3e50;
line-height: 1.6;
}
.App-logo {
height: 40vmin;
pointer-events: none;
* {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -1,25 +1,30 @@
import React from 'react';
import logo from './logo.svg';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout';
import ErrorBoundary from './components/ErrorBoundary';
import ResourcesList from './pages/ResourcesList';
import ResourceCalendar from './pages/ResourceCalendar';
import CreateReservation from './pages/CreateReservation';
import UserDashboard from './pages/UserDashboard';
import AdminDashboard from './pages/AdminDashboard';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
<Router>
<ErrorBoundary>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<UserDashboard />} />
<Route path="/resources" element={<ResourcesList />} />
<Route path="/resources/:id/calendar" element={<ResourceCalendar />} />
<Route path="/reservations/new" element={<CreateReservation />} />
<Route path="/admin" element={<AdminDashboard />} />
</Routes>
</Layout>
</ErrorBoundary>
</Router>
);
}

View File

@ -0,0 +1,74 @@
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends React.Component<
React.PropsWithChildren<{}>,
ErrorBoundaryState
> {
constructor(props: React.PropsWithChildren<{}>) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div style={{
padding: '2rem',
textAlign: 'center',
backgroundColor: '#f8d7da',
color: '#721c24',
margin: '1rem',
borderRadius: '4px',
border: '1px solid #f5c6cb'
}}>
<h2>Something went wrong</h2>
<p>The application encountered an error. Please try refreshing the page.</p>
<details style={{ marginTop: '1rem', textAlign: 'left' }}>
<summary>Error details</summary>
<pre style={{
background: '#f8f9fa',
padding: '1rem',
borderRadius: '4px',
overflow: 'auto',
fontSize: '0.8rem',
marginTop: '0.5rem'
}}>
{this.state.error?.stack}
</pre>
</details>
<button
onClick={() => window.location.reload()}
style={{
marginTop: '1rem',
padding: '0.75rem 1.5rem',
backgroundColor: '#e74c3c',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Refresh Page
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

218
src/components/Layout.css Normal file
View File

@ -0,0 +1,218 @@
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background-color: #2c3e50;
color: white;
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: relative;
}
.nav-brand {
text-decoration: none;
color: white;
transition: background-color 0.2s;
border-radius: 4px;
padding: 0.25rem 0.5rem;
}
.nav-brand:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.nav-brand h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
/* Desktop Navigation */
.nav-links-desktop {
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 2rem;
}
/* Mobile Menu Button */
.mobile-menu-btn {
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
width: 32px;
height: 32px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s;
}
.mobile-menu-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.hamburger-line {
width: 20px;
height: 2px;
background-color: white;
margin: 2px 0;
transition: all 0.3s ease;
transform-origin: center;
}
.mobile-menu-btn[aria-expanded="true"] .hamburger-line:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.mobile-menu-btn[aria-expanded="true"] .hamburger-line:nth-child(2) {
opacity: 0;
transform: translateX(10px);
}
.mobile-menu-btn[aria-expanded="true"] .hamburger-line:nth-child(3) {
transform: rotate(-45deg) translate(5px, -5px);
}
/* Mobile Menu */
.mobile-menu {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
}
.mobile-menu-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.nav-links-mobile {
position: absolute;
top: 0;
right: 0;
background-color: #2c3e50;
width: 280px;
height: 100vh;
list-style: none;
margin: 0;
padding: 0;
transform: translateX(100%);
transition: transform 0.3s ease;
box-shadow: -2px 0 10px rgba(0,0,0,0.1);
}
.mobile-menu.open .nav-links-mobile {
transform: translateX(0);
}
.nav-links-mobile li {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.nav-links-mobile li:last-child {
border-bottom: none;
}
/* Common Navigation Styles */
.nav-links a {
color: white;
text-decoration: none;
padding: 1rem 1.5rem;
border-radius: 4px;
transition: background-color 0.2s;
display: block;
font-size: 1rem;
}
.nav-links-desktop a {
padding: 0.5rem 1rem;
}
.nav-links a:hover,
.nav-links a.active {
background-color: #34495e;
}
.nav-brand:hover {
background-color: #34495e;
}
.main-content {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* Responsive Design */
@media (max-width: 768px) {
.navbar {
padding: 1rem;
}
.nav-brand h1 {
font-size: 1.1rem;
}
.nav-links-desktop {
display: none;
}
.mobile-menu-btn {
display: flex;
}
.mobile-menu {
display: block;
}
.main-content {
padding: 1rem;
}
}
@media (max-width: 480px) {
.navbar {
padding: 0.75rem 1rem;
}
.nav-brand h1 {
font-size: 1rem;
}
.mobile-menu-btn {
width: 28px;
height: 28px;
}
.hamburger-line {
width: 18px;
height: 2px;
}
.nav-links-mobile {
width: 250px;
}
.main-content {
padding: 0.75rem;
}
}

112
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,112 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import './Layout.css';
interface LayoutProps {
children: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const toggleMobileMenu = () => {
try {
setIsMobileMenuOpen(!isMobileMenuOpen);
} catch (error) {
console.error('Error toggling mobile menu:', error);
}
};
const closeMobileMenu = () => {
try {
setIsMobileMenuOpen(false);
} catch (error) {
console.error('Error closing mobile menu:', error);
}
};
const navLinks = [
{
to: '/dashboard',
label: 'My Reservations',
isActive: location.pathname === '/dashboard'
},
{
to: '/resources',
label: 'Resources',
isActive: location.pathname.startsWith('/resources')
},
{
to: '/reservations/new',
label: 'Book Resource',
isActive: location.pathname === '/reservations/new'
},
{
to: '/admin',
label: 'Admin',
isActive: location.pathname === '/admin'
}
];
return (
<div className="layout">
<nav className="navbar">
<Link to="/dashboard" className="nav-brand" onClick={closeMobileMenu}>
<h1>LibreBooking UI</h1>
</Link>
{/* Mobile Menu Button */}
<button
className="mobile-menu-btn"
onClick={toggleMobileMenu}
aria-label="Toggle navigation menu"
aria-expanded={isMobileMenuOpen}
>
<span className="hamburger-line"></span>
<span className="hamburger-line"></span>
<span className="hamburger-line"></span>
</button>
{/* Desktop Navigation */}
<ul className="nav-links nav-links-desktop">
{navLinks.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className={link.isActive ? 'active' : ''}
>
{link.label}
</Link>
</li>
))}
</ul>
{/* Mobile Navigation */}
{isMobileMenuOpen && (
<div className="mobile-menu open">
<div className="mobile-menu-backdrop" onClick={closeMobileMenu}></div>
<ul className="nav-links nav-links-mobile">
{navLinks.map((link) => (
<li key={link.to}>
<Link
to={link.to}
className={link.isActive ? 'active' : ''}
onClick={closeMobileMenu}
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
)}
</nav>
<main className="main-content">
{children}
</main>
</div>
);
};
export default Layout;

View File

@ -0,0 +1,217 @@
.admin-dashboard {
width: 100%;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.page-header p {
margin: 0;
color: #7f8c8d;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}
.stat-card h3 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
color: #3498db;
}
.stat-card p {
margin: 0;
color: #7f8c8d;
font-weight: 500;
}
.reservations-sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
.reservations-section {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.reservations-section h2 {
margin: 0;
padding: 1.5rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
color: #2c3e50;
}
.reservations-list {
padding: 1rem;
}
.reservation-card {
border: 1px solid #dee2e6;
border-radius: 6px;
margin-bottom: 1rem;
padding: 1.5rem;
background: white;
transition: box-shadow 0.2s;
}
.reservation-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.reservation-card:last-child {
margin-bottom: 0;
}
.reservation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.reservation-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.2rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
color: white;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.reservation-details {
display: grid;
gap: 0.5rem;
margin-bottom: 1rem;
}
.detail-row {
display: flex;
gap: 0.5rem;
color: #555;
font-size: 0.9rem;
}
.detail-row strong {
color: #2c3e50;
min-width: 80px;
}
.reservation-actions {
display: flex;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
font-weight: 500;
text-decoration: none;
text-align: center;
display: inline-block;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-confirm {
background-color: #27ae60;
color: white;
}
.btn-confirm:hover:not(:disabled) {
background-color: #229954;
}
.btn-cancel {
background-color: #e74c3c;
color: white;
}
.btn-cancel:hover:not(:disabled) {
background-color: #c0392b;
}
.no-reservations {
text-align: center;
color: #7f8c8d;
padding: 2rem;
font-style: italic;
}
.loading,
.error {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
}
.error {
color: #e74c3c;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.reservation-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.reservation-actions {
flex-direction: column;
}
.detail-row {
flex-direction: column;
gap: 0.25rem;
}
.detail-row strong {
min-width: auto;
}
}

View File

@ -0,0 +1,226 @@
import React, { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { api } from '../services/api';
import { Reservation } from '../types';
import './AdminDashboard.css';
const AdminDashboard: React.FC = () => {
const [reservations, setReservations] = useState<Reservation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState<string | null>(null);
useEffect(() => {
loadReservations();
}, []);
const loadReservations = async () => {
try {
setLoading(true);
const data = await api.getReservations();
// Sort by creation date, newest first
const sortedData = data.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setReservations(sortedData);
} catch (err) {
setError('Failed to load reservations');
} finally {
setLoading(false);
}
};
const handleConfirmReservation = async (reservationId: string) => {
try {
setProcessing(reservationId);
await api.updateReservationStatus(reservationId, 'confirmed');
// Update local state
setReservations(prev =>
prev.map(r =>
r.id === reservationId
? { ...r, status: 'confirmed' as const }
: r
)
);
} catch (err) {
setError('Failed to confirm reservation');
} finally {
setProcessing(null);
}
};
const handleCancelReservation = async (reservationId: string) => {
try {
setProcessing(reservationId);
await api.updateReservationStatus(reservationId, 'cancelled');
// Update local state
setReservations(prev =>
prev.map(r =>
r.id === reservationId
? { ...r, status: 'cancelled' as const }
: r
)
);
} catch (err) {
setError('Failed to cancel reservation');
} finally {
setProcessing(null);
}
};
const pendingReservations = reservations.filter(r => r.status === 'pending');
const confirmedReservations = reservations.filter(r => r.status === 'confirmed');
const cancelledReservations = reservations.filter(r => r.status === 'cancelled');
if (loading) {
return <div className="loading">Loading admin dashboard...</div>;
}
return (
<div className="admin-dashboard">
<div className="page-header">
<h1>Admin Dashboard</h1>
<p>Manage resource reservations</p>
</div>
{error && <div className="error">{error}</div>}
<div className="stats-grid">
<div className="stat-card">
<h3>{pendingReservations.length}</h3>
<p>Pending Reservations</p>
</div>
<div className="stat-card">
<h3>{confirmedReservations.length}</h3>
<p>Confirmed Reservations</p>
</div>
<div className="stat-card">
<h3>{cancelledReservations.length}</h3>
<p>Cancelled Reservations</p>
</div>
</div>
<div className="reservations-sections">
{pendingReservations.length > 0 && (
<section className="reservations-section">
<h2>Pending Reservations</h2>
<div className="reservations-list">
{pendingReservations.map(reservation => (
<ReservationCard
key={reservation.id}
reservation={reservation}
onConfirm={() => handleConfirmReservation(reservation.id)}
onCancel={() => handleCancelReservation(reservation.id)}
processing={processing === reservation.id}
/>
))}
</div>
</section>
)}
<section className="reservations-section">
<h2>All Reservations</h2>
<div className="reservations-list">
{reservations.length === 0 ? (
<p className="no-reservations">No reservations found</p>
) : (
reservations.map(reservation => (
<ReservationCard
key={reservation.id}
reservation={reservation}
onConfirm={reservation.status === 'pending' ? () => handleConfirmReservation(reservation.id) : undefined}
onCancel={reservation.status === 'pending' ? () => handleCancelReservation(reservation.id) : undefined}
processing={processing === reservation.id}
/>
))
)}
</div>
</section>
</div>
</div>
);
};
interface ReservationCardProps {
reservation: Reservation;
onConfirm?: () => void;
onCancel?: () => void;
processing: boolean;
}
const ReservationCard: React.FC<ReservationCardProps> = ({
reservation,
onConfirm,
onCancel,
processing
}) => {
const statusColors = {
pending: '#f39c12',
confirmed: '#27ae60',
cancelled: '#e74c3c'
};
return (
<div className="reservation-card">
<div className="reservation-header">
<h3>{reservation.title}</h3>
<span
className="status-badge"
style={{ backgroundColor: statusColors[reservation.status] }}
>
{reservation.status}
</span>
</div>
<div className="reservation-details">
<div className="detail-row">
<strong>Resource:</strong> {reservation.resource.name}
</div>
<div className="detail-row">
<strong>Location:</strong> {reservation.resource.location}
</div>
<div className="detail-row">
<strong>Date:</strong> {format(new Date(reservation.startTime), 'MMMM d, yyyy')}
</div>
<div className="detail-row">
<strong>Time:</strong> {format(new Date(reservation.startTime), 'h:mm a')} - {format(new Date(reservation.endTime), 'h:mm a')}
</div>
{reservation.description && (
<div className="detail-row">
<strong>Description:</strong> {reservation.description}
</div>
)}
<div className="detail-row">
<strong>Created:</strong> {format(new Date(reservation.createdAt), 'MMM d, yyyy h:mm a')}
</div>
</div>
{(onConfirm || onCancel) && (
<div className="reservation-actions">
{onConfirm && (
<button
className="btn btn-confirm"
onClick={onConfirm}
disabled={processing}
>
{processing ? 'Processing...' : 'Confirm'}
</button>
)}
{onCancel && (
<button
className="btn btn-cancel"
onClick={onCancel}
disabled={processing}
>
{processing ? 'Processing...' : 'Cancel'}
</button>
)}
</div>
)}
</div>
);
};
export default AdminDashboard;

View File

@ -0,0 +1,142 @@
.create-reservation {
max-width: 600px;
margin: 0 auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
margin: 0;
color: #2c3e50;
}
.reservation-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #2c3e50;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #3498db;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #eee;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
text-decoration: none;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
font-weight: 500;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #7f8c8d;
}
.error {
background-color: #f8d7da;
color: #721c24;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
border: 1px solid #f5c6cb;
}
.loading,
.no-resources {
text-align: center;
padding: 2rem;
color: #7f8c8d;
}
.no-resources h2 {
color: #2c3e50;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.create-reservation {
max-width: none;
}
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
.reservation-form {
padding: 1.5rem;
}
}

View File

@ -0,0 +1,189 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { api } from '../services/api';
import { Resource, CreateReservationRequest } from '../types';
import './CreateReservation.css';
const CreateReservation: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [resources, setResources] = useState<Resource[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const preselectedResourceId = searchParams.get('resourceId');
const [formData, setFormData] = useState<CreateReservationRequest>({
resourceId: preselectedResourceId || '',
startTime: new Date(),
endTime: new Date(),
title: '',
description: '',
});
useEffect(() => {
loadResources();
}, []);
const loadResources = async () => {
try {
setLoading(true);
const data = await api.getResources();
const availableResources = data.filter(r => r.isAvailable);
setResources(availableResources);
// If no preselected resource but we have available ones, select the first
if (!preselectedResourceId && availableResources.length > 0) {
setFormData(prev => ({ ...prev, resourceId: availableResources[0].id }));
}
} catch (err) {
setError('Failed to load resources');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.resourceId || !formData.title) {
setError('Please fill in all required fields');
return;
}
if (formData.startTime >= formData.endTime) {
setError('End time must be after start time');
return;
}
try {
setSubmitting(true);
setError(null);
await api.createReservation(formData);
// Navigate to resources list on success
navigate('/resources', {
state: { message: 'Reservation created successfully!' }
});
} catch (err) {
setError('Failed to create reservation');
} finally {
setSubmitting(false);
}
};
const handleInputChange = (field: keyof CreateReservationRequest, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
if (loading) {
return <div className="loading">Loading resources...</div>;
}
if (resources.length === 0) {
return (
<div className="no-resources">
<h2>No Available Resources</h2>
<p>There are currently no available resources to book.</p>
</div>
);
}
return (
<div className="create-reservation">
<div className="page-header">
<h1>Create New Reservation</h1>
</div>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit} className="reservation-form">
<div className="form-group">
<label htmlFor="resourceId">Resource *</label>
<select
id="resourceId"
value={formData.resourceId}
onChange={(e) => handleInputChange('resourceId', e.target.value)}
required
>
<option value="">Select a resource</option>
{resources.map(resource => (
<option key={resource.id} value={resource.id}>
{resource.name} - {resource.location}
</option>
))}
</select>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="startTime">Start Time *</label>
<input
type="datetime-local"
id="startTime"
value={formData.startTime.toISOString().slice(0, 16)}
onChange={(e) => handleInputChange('startTime', new Date(e.target.value))}
required
/>
</div>
<div className="form-group">
<label htmlFor="endTime">End Time *</label>
<input
type="datetime-local"
id="endTime"
value={formData.endTime.toISOString().slice(0, 16)}
onChange={(e) => handleInputChange('endTime', new Date(e.target.value))}
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="title">Title *</label>
<input
type="text"
id="title"
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
placeholder="Meeting title or purpose"
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Additional details about this reservation"
rows={4}
/>
</div>
<div className="form-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate('/resources')}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={submitting}
>
{submitting ? 'Creating...' : 'Create Reservation'}
</button>
</div>
</form>
</div>
);
};
export default CreateReservation;

View File

@ -0,0 +1,213 @@
.resource-calendar {
width: 100%;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
gap: 1rem;
flex-wrap: wrap;
}
.header-info h1 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.resource-details {
margin: 0;
color: #7f8c8d;
font-size: 0.9rem;
}
.header-actions {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: flex-end;
}
.week-navigation {
display: flex;
align-items: center;
gap: 1rem;
}
.current-week {
font-weight: 600;
color: #2c3e50;
min-width: 150px;
text-align: center;
}
.calendar-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: auto;
}
.calendar-grid {
display: grid;
grid-template-columns: 100px repeat(7, 1fr);
min-width: 800px;
}
.time-header {
padding: 1rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
border-right: 1px solid #dee2e6;
}
.day-header {
padding: 1rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
border-right: 1px solid #dee2e6;
text-align: center;
}
.day-name {
font-weight: 600;
color: #2c3e50;
font-size: 0.9rem;
}
.day-date {
color: #7f8c8d;
font-size: 0.8rem;
}
.time-label {
padding: 1rem;
background: #f8f9fa;
border-right: 1px solid #dee2e6;
border-bottom: 1px solid #dee2e6;
font-size: 0.8rem;
color: #6c757d;
text-align: center;
}
.time-slot {
min-height: 60px;
padding: 0.5rem;
border-right: 1px solid #dee2e6;
border-bottom: 1px solid #dee2e6;
background: white;
}
.time-slot.today {
background: #fff3cd;
}
.reservation-item {
background: #3498db;
color: white;
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 0.25rem;
font-size: 0.8rem;
}
.reservation-item:last-child {
margin-bottom: 0;
}
.reservation-item.pending {
background: #f39c12;
}
.reservation-item.cancelled {
background: #e74c3c;
}
.reservation-title {
font-weight: 600;
margin-bottom: 0.25rem;
}
.reservation-time {
font-size: 0.7rem;
opacity: 0.9;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
text-align: center;
white-space: nowrap;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
.loading,
.error {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
}
.error {
color: #e74c3c;
}
@media (max-width: 1200px) {
.calendar-header {
flex-direction: column;
align-items: stretch;
}
.header-actions {
align-items: stretch;
}
.week-navigation {
justify-content: center;
}
}
@media (max-width: 768px) {
.calendar-grid {
grid-template-columns: 80px repeat(7, 1fr);
min-width: 600px;
}
.day-header,
.time-label,
.time-slot {
padding: 0.5rem;
}
.time-slot {
min-height: 50px;
}
.reservation-item {
font-size: 0.7rem;
padding: 0.25rem;
}
}

View File

@ -0,0 +1,195 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { format, startOfWeek, addDays, startOfDay, addHours, isSameDay, isWithinInterval } from 'date-fns';
import { api } from '../services/api';
import { Resource, Reservation } from '../types';
import './ResourceCalendar.css';
const ResourceCalendar: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [resource, setResource] = useState<Resource | null>(null);
const [reservations, setReservations] = useState<Reservation[]>([]);
const [currentWeek, setCurrentWeek] = useState(new Date());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (id) {
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, currentWeek]);
const loadData = async () => {
if (!id) {
setError('Invalid resource ID');
return;
}
try {
setLoading(true);
setError(null);
const [resourceData, reservationsData] = await Promise.all([
api.getResource(id),
api.getReservations(id)
]);
if (!resourceData) {
setError('Resource not found');
return;
}
console.log('Calendar - Resource:', resourceData);
console.log('Calendar - Reservations:', reservationsData);
setResource(resourceData);
setReservations(Array.isArray(reservationsData) ? reservationsData : []);
} catch (err) {
console.error('Calendar load error:', err);
setError('Failed to load resource calendar');
} finally {
setLoading(false);
}
};
const weekStart = startOfWeek(currentWeek, { weekStartsOn: 1 }); // Start on Monday
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
// Generate time slots from 8 AM to 8 PM
const timeSlots = Array.from({ length: 13 }, (_, i) => addHours(startOfDay(weekStart), 8 + i));
const getReservationsForSlot = (date: Date, timeSlot: Date): Reservation[] => {
if (!date || !timeSlot) return [];
const slotStart = timeSlot;
const slotEnd = addHours(timeSlot, 1);
const filtered = reservations.filter(reservation => {
if (!reservation?.startTime) return false;
const reservationDate = new Date(reservation.startTime);
if (isNaN(reservationDate.getTime())) return false;
const matches = isSameDay(reservationDate, date) &&
isWithinInterval(reservationDate, { start: slotStart, end: slotEnd });
if (matches) {
console.log('Found reservation for slot:', {
title: reservation.title,
date: reservationDate,
slotStart,
slotEnd,
matches
});
}
return matches;
});
return filtered;
};
const navigateWeek = (direction: 'prev' | 'next') => {
setCurrentWeek(prev => addDays(prev, direction === 'next' ? 7 : -7));
};
const goToToday = () => {
setCurrentWeek(new Date());
};
if (loading) {
return <div className="loading">Loading calendar...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
if (!resource) {
return <div className="error">Resource not found</div>;
}
return (
<div className="resource-calendar">
<div className="calendar-header">
<div className="header-info">
<h1>{resource.name} - Calendar</h1>
<p className="resource-details">
{resource.location} Capacity: {resource.capacity}
</p>
</div>
<div className="header-actions">
<div className="week-navigation">
<button onClick={() => navigateWeek('prev')} className="btn btn-secondary">
Previous
</button>
<button onClick={goToToday} className="btn btn-secondary">
Today
</button>
<span className="current-week">
{format(weekStart, 'MMM d')} - {format(addDays(weekStart, 6), 'MMM d, yyyy')}
</span>
<button onClick={() => navigateWeek('next')} className="btn btn-secondary">
Next
</button>
</div>
<Link to="/reservations/new" className="btn btn-primary">
Book Resource
</Link>
</div>
</div>
<div className="calendar-container">
<div className="calendar-grid">
{/* Time column header */}
<div className="time-header"></div>
{/* Day headers */}
{weekDays.map(day => (
<div key={day.toString()} className="day-header">
<div className="day-name">{format(day, 'EEE')}</div>
<div className="day-date">{format(day, 'd')}</div>
</div>
))}
{/* Time slots */}
{timeSlots.map(timeSlot => (
<React.Fragment key={timeSlot.toString()}>
<div className="time-label">
{format(timeSlot, 'h:mm a')}
</div>
{weekDays.map(day => {
const slotReservations = getReservationsForSlot(day, timeSlot);
const isToday = isSameDay(day, new Date());
return (
<div
key={`${day.toString()}-${timeSlot.toString()}`}
className={`time-slot ${isToday ? 'today' : ''}`}
>
{slotReservations.map(reservation => (
<div
key={reservation.id}
className={`reservation-item ${reservation.status}`}
>
<div className="reservation-title">{reservation.title}</div>
<div className="reservation-time">
{format(new Date(reservation.startTime), 'h:mm a')} -
{format(new Date(reservation.endTime), 'h:mm a')}
</div>
</div>
))}
</div>
);
})}
</React.Fragment>
))}
</div>
</div>
</div>
);
};
export default ResourceCalendar;

189
src/pages/ResourcesList.css Normal file
View File

@ -0,0 +1,189 @@
.resources-list {
width: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
gap: 1rem;
}
.page-header h1 {
margin: 0;
color: #2c3e50;
}
.resources-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 2rem;
}
.resource-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.resource-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.resource-image {
height: 200px;
background: #ecf0f1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.resource-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder-image {
font-size: 3rem;
color: #95a5a6;
}
.resource-content {
padding: 1.5rem;
}
.resource-content h3 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
font-size: 1.25rem;
}
.resource-description {
color: #7f8c8d;
margin: 0 0 1rem 0;
line-height: 1.5;
}
.resource-location,
.resource-capacity {
margin: 0.5rem 0;
color: #34495e;
font-size: 0.9rem;
}
.resource-amenities {
margin: 1rem 0;
}
.resource-amenities strong {
color: #2c3e50;
font-size: 0.9rem;
}
.resource-amenities ul {
margin: 0.5rem 0 0 0;
padding-left: 1.5rem;
color: #7f8c8d;
font-size: 0.85rem;
}
.resource-amenities li {
margin: 0.25rem 0;
}
.resource-status {
margin: 1rem 0;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.available {
background-color: #d4edda;
color: #155724;
}
.status-badge.unavailable {
background-color: #f8d7da;
color: #721c24;
}
.resource-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
text-align: center;
flex: 1;
}
.btn:disabled,
.btn.disabled {
opacity: 0.6;
cursor: not-allowed;
text-decoration: none;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #7f8c8d;
}
.loading, .error {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
}
.error {
color: #e74c3c;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.resources-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.resource-actions {
flex-direction: column;
}
}

113
src/pages/ResourcesList.tsx Normal file
View File

@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../services/api';
import { Resource } from '../types';
import './ResourcesList.css';
const ResourcesList: React.FC = () => {
const [resources, setResources] = useState<Resource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadResources();
}, []);
const loadResources = async () => {
try {
setLoading(true);
const data = await api.getResources();
setResources(data);
} catch (err) {
setError('Failed to load resources');
} finally {
setLoading(false);
}
};
if (loading) {
return <div className="loading">Loading resources...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
return (
<div className="resources-list">
<div className="page-header">
<h1>Available Resources</h1>
<Link to="/reservations/new" className="btn btn-primary">
New Reservation
</Link>
</div>
<div className="resources-grid">
{resources.map(resource => (
<div key={resource.id} className="resource-card">
<div className="resource-image">
{resource.imageUrl ? (
<img src={resource.imageUrl} alt={resource.name} />
) : (
<div className="placeholder-image">🏢</div>
)}
</div>
<div className="resource-content">
<h3>{resource.name}</h3>
<p className="resource-description">{resource.description}</p>
{resource.location && (
<p className="resource-location">📍 {resource.location}</p>
)}
{resource.capacity && (
<p className="resource-capacity">👥 Capacity: {resource.capacity}</p>
)}
{resource.amenities && resource.amenities.length > 0 && (
<div className="resource-amenities">
<strong>Amenities:</strong>
<ul>
{resource.amenities.map((amenity, index) => (
<li key={index}>{amenity}</li>
))}
</ul>
</div>
)}
<div className="resource-status">
<span className={`status-badge ${resource.isAvailable ? 'available' : 'unavailable'}`}>
{resource.isAvailable ? 'Available' : 'Unavailable'}
</span>
</div>
<div className="resource-actions">
<Link
to={`/resources/${resource.id}/calendar`}
className="btn btn-secondary"
>
View Calendar
</Link>
{resource.isAvailable ? (
<Link
to={`/reservations/new?resourceId=${resource.id}`}
className="btn btn-primary"
>
Book Now
</Link>
) : (
<span className="btn btn-primary disabled">
Book Now
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default ResourcesList;

361
src/pages/UserDashboard.css Normal file
View File

@ -0,0 +1,361 @@
.user-dashboard {
width: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
gap: 1rem;
flex-wrap: wrap;
}
.header-content h1 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.header-content p {
margin: 0;
color: #7f8c8d;
}
.header-actions {
display: flex;
gap: 1rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}
.stat-card h3 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
color: #3498db;
}
.stat-card p {
margin: 0;
color: #7f8c8d;
font-weight: 500;
}
.filters-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.filter-tabs {
display: flex;
gap: 0.5rem;
background: white;
padding: 0.25rem;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.filter-tab {
padding: 0.75rem 1.5rem;
border: none;
background: transparent;
color: #7f8c8d;
cursor: pointer;
border-radius: 4px;
font-weight: 500;
transition: all 0.2s;
}
.filter-tab:hover {
background: #f8f9fa;
}
.filter-tab.active {
background: #3498db;
color: white;
}
.date-filter {
display: flex;
align-items: center;
gap: 0.5rem;
background: white;
padding: 0.5rem;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.date-input {
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem;
font-size: 0.9rem;
}
.clear-date-btn {
background: none;
border: none;
color: #7f8c8d;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 1rem;
}
.clear-date-btn:hover {
background: #f8f9fa;
color: #2c3e50;
}
.reservations-section {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.section-header h2 {
margin: 0;
color: #2c3e50;
font-size: 1.25rem;
}
.result-count {
color: #7f8c8d;
font-size: 0.9rem;
}
.reservations-list {
padding: 1rem;
}
.reservation-card {
border: 1px solid #dee2e6;
border-radius: 6px;
margin-bottom: 1rem;
padding: 1.5rem;
background: white;
transition: box-shadow 0.2s;
}
.reservation-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.reservation-card:last-child {
margin-bottom: 0;
}
.reservation-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
gap: 1rem;
}
.reservation-info h3 {
margin: 0 0 0.25rem 0;
color: #2c3e50;
font-size: 1.2rem;
}
.resource-name {
margin: 0;
color: #7f8c8d;
font-size: 0.9rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
color: white;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
}
.reservation-details {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
}
.detail-group {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
.detail-item {
display: flex;
align-items: center;
gap: 0.5rem;
color: #555;
font-size: 0.9rem;
}
.detail-icon {
font-size: 1rem;
}
.detail-item.description {
width: 100%;
align-items: flex-start;
}
.reservation-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid #eee;
gap: 1rem;
}
.pending-note {
color: #f39c12;
font-size: 0.9rem;
font-weight: 500;
}
.no-reservations {
text-align: center;
padding: 3rem 2rem;
color: #7f8c8d;
}
.no-reservations-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.no-reservations h3 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
}
.no-reservations p {
margin: 0 0 1.5rem 0;
font-size: 0.95rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
font-weight: 500;
text-decoration: none;
text-align: center;
display: inline-block;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
.loading,
.error {
text-align: center;
padding: 2rem;
font-size: 1.1rem;
}
.error {
color: #e74c3c;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.filters-section {
flex-direction: column;
align-items: stretch;
}
.filter-tabs {
justify-content: center;
}
.date-filter {
justify-content: center;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.reservation-header {
flex-direction: column;
align-items: flex-start;
}
.detail-group {
flex-direction: column;
gap: 0.75rem;
}
.reservation-actions {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.stats-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
}

282
src/pages/UserDashboard.tsx Normal file
View File

@ -0,0 +1,282 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { format, startOfDay, isAfter, isBefore } from 'date-fns';
import { api } from '../services/api';
import { Reservation } from '../types';
import './UserDashboard.css';
const UserDashboard: React.FC = () => {
const [reservations, setReservations] = useState<Reservation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
useEffect(() => {
loadReservations();
}, []);
const loadReservations = async () => {
try {
setLoading(true);
const data = await api.getReservations();
// Sort by start date, upcoming first
const sortedData = data.sort((a, b) =>
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
);
setReservations(sortedData);
} catch (err) {
setError('Failed to load reservations');
} finally {
setLoading(false);
}
};
const filterReservations = (reservations: Reservation[]) => {
const now = new Date();
const today = startOfDay(now);
switch (filter) {
case 'upcoming':
return reservations.filter(r =>
isAfter(new Date(r.startTime), today) ||
isSameDay(new Date(r.startTime), today)
);
case 'past':
return reservations.filter(r =>
isBefore(new Date(r.startTime), today)
);
default:
return reservations;
}
};
const filterByDate = (reservations: Reservation[]) => {
if (!selectedDate) return reservations;
return reservations.filter(r =>
isSameDay(new Date(r.startTime), selectedDate)
);
};
const getFilteredReservations = () => {
let filtered = filterReservations(reservations);
filtered = filterByDate(filtered);
return filtered;
};
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed':
return '#27ae60';
case 'pending':
return '#f39c12';
case 'cancelled':
return '#e74c3c';
default:
return '#95a5a6';
}
};
const isSameDay = (date1: Date, date2: Date) => {
return format(date1, 'yyyy-MM-dd') === format(date2, 'yyyy-MM-dd');
};
const clearDateFilter = () => {
setSelectedDate(null);
};
const upcomingReservations = filterReservations(reservations).filter(r =>
isAfter(new Date(r.startTime), new Date()) ||
isSameDay(new Date(r.startTime), new Date())
);
if (loading) {
return <div className="loading">Loading reservations...</div>;
}
const filteredReservations = getFilteredReservations();
return (
<div className="user-dashboard">
<div className="page-header">
<div className="header-content">
<h1>My Reservations</h1>
<p>View and manage all your resource bookings</p>
</div>
<div className="header-actions">
<Link to="/reservations/new" className="btn btn-primary">
New Reservation
</Link>
</div>
</div>
{error && <div className="error">{error}</div>}
{/* Quick Stats */}
<div className="stats-grid">
<div className="stat-card">
<h3>{upcomingReservations.length}</h3>
<p>Upcoming Reservations</p>
</div>
<div className="stat-card">
<h3>{upcomingReservations.filter(r => r.status === 'confirmed').length}</h3>
<p>Confirmed Bookings</p>
</div>
<div className="stat-card">
<h3>{upcomingReservations.filter(r => r.status === 'pending').length}</h3>
<p>Pending Approval</p>
</div>
</div>
{/* Filters */}
<div className="filters-section">
<div className="filter-tabs">
<button
className={`filter-tab ${filter === 'upcoming' ? 'active' : ''}`}
onClick={() => setFilter('upcoming')}
>
Upcoming
</button>
<button
className={`filter-tab ${filter === 'past' ? 'active' : ''}`}
onClick={() => setFilter('past')}
>
Past
</button>
<button
className={`filter-tab ${filter === 'all' ? 'active' : ''}`}
onClick={() => setFilter('all')}
>
All
</button>
</div>
<div className="date-filter">
<input
type="date"
value={selectedDate ? format(selectedDate, 'yyyy-MM-dd') : ''}
onChange={(e) => setSelectedDate(e.target.value ? new Date(e.target.value) : null)}
className="date-input"
/>
{selectedDate && (
<button onClick={clearDateFilter} className="clear-date-btn">
</button>
)}
</div>
</div>
{/* Reservations List */}
<div className="reservations-section">
<div className="section-header">
<h2>
{filter === 'upcoming' && 'Upcoming Reservations'}
{filter === 'past' && 'Past Reservations'}
{filter === 'all' && 'All Reservations'}
{selectedDate && ` - ${format(selectedDate, 'MMM d, yyyy')}`}
</h2>
<span className="result-count">
{filteredReservations.length} {filteredReservations.length === 1 ? 'reservation' : 'reservations'}
</span>
</div>
{filteredReservations.length === 0 ? (
<div className="no-reservations">
<div className="no-reservations-icon">📅</div>
<h3>No reservations found</h3>
<p>
{selectedDate
? `No reservations found for ${format(selectedDate, 'MMMM d, yyyy')}`
: filter === 'upcoming'
? 'You have no upcoming reservations'
: filter === 'past'
? 'You have no past reservations'
: 'No reservations found'
}
</p>
{!selectedDate && filter === 'upcoming' && (
<Link to="/reservations/new" className="btn btn-primary">
Create Your First Reservation
</Link>
)}
</div>
) : (
<div className="reservations-list">
{filteredReservations.map(reservation => (
<div key={reservation.id} className="reservation-card">
<div className="reservation-header">
<div className="reservation-info">
<h3>{reservation.title}</h3>
<p className="resource-name">{reservation.resource.name}</p>
</div>
<div className="reservation-status">
<span
className="status-badge"
style={{ backgroundColor: getStatusColor(reservation.status) }}
>
{reservation.status}
</span>
</div>
</div>
<div className="reservation-details">
<div className="detail-group">
<div className="detail-item">
<span className="detail-icon">📅</span>
<span>{format(new Date(reservation.startTime), 'EEEE, MMMM d, yyyy')}</span>
</div>
<div className="detail-item">
<span className="detail-icon">🕐</span>
<span>
{format(new Date(reservation.startTime), 'h:mm a')} -
{format(new Date(reservation.endTime), 'h:mm a')}
</span>
</div>
</div>
<div className="detail-group">
<div className="detail-item">
<span className="detail-icon">📍</span>
<span>{reservation.resource.location}</span>
</div>
{reservation.resource.capacity && (
<div className="detail-item">
<span className="detail-icon">👥</span>
<span>Capacity: {reservation.resource.capacity}</span>
</div>
)}
</div>
{reservation.description && (
<div className="detail-group">
<div className="detail-item description">
<span className="detail-icon">📝</span>
<span>{reservation.description}</span>
</div>
</div>
)}
</div>
<div className="reservation-actions">
<Link
to={`/resources/${reservation.resourceId}/calendar`}
className="btn btn-secondary"
>
View Calendar
</Link>
{reservation.status === 'pending' && (
<div className="pending-note">
Awaiting approval
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default UserDashboard;

131
src/services/api.ts Normal file
View File

@ -0,0 +1,131 @@
import { Resource, Reservation, CreateReservationRequest } from '../types';
// Mock data for development - will be replaced with real API calls
const mockResources: Resource[] = [
{
id: '1',
name: 'Conference Room A',
description: 'Large conference room with projector and whiteboard',
location: 'Building 1, Floor 2',
capacity: 20,
imageUrl: '/images/conference-room.jpg',
amenities: ['Projector', 'Whiteboard', 'Video Conference'],
isAvailable: true,
},
{
id: '2',
name: 'Meeting Room B',
description: 'Small meeting room for team discussions',
location: 'Building 1, Floor 1',
capacity: 6,
amenities: ['TV', 'Whiteboard'],
isAvailable: true,
},
{
id: '3',
name: 'Training Room',
description: 'Spacious training room with multiple screens',
location: 'Building 2, Floor 3',
capacity: 30,
amenities: ['Projector', 'Sound System', 'Recording Equipment'],
isAvailable: false,
},
];
const mockReservations: Reservation[] = [
{
id: '1',
resourceId: '1',
resource: mockResources[0],
userId: 'user1',
startTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}T10:00:00`),
endTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}T12:00:00`),
title: 'Team Meeting',
description: 'Weekly team sync',
status: 'confirmed',
createdAt: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate() - 2).padStart(2, '0')}T09:00:00`),
},
{
id: '2',
resourceId: '2',
resource: mockResources[1],
userId: 'user2',
startTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}T14:00:00`),
endTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}T15:30:00`),
title: 'Client Presentation',
status: 'pending',
createdAt: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate() - 1).padStart(2, '0')}T11:00:00`),
},
{
id: '3',
resourceId: '1',
resource: mockResources[0],
userId: 'user3',
startTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate() + 1).padStart(2, '0')}T09:00:00`),
endTime: new Date(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate() + 1).padStart(2, '0')}T11:00:00`),
title: 'Project Planning',
description: 'Q1 planning session',
status: 'pending',
createdAt: new Date(),
},
];
export const api = {
// Resources
getResources: async (): Promise<Resource[]> => {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500));
return mockResources;
},
getResource: async (id: string): Promise<Resource | null> => {
await new Promise(resolve => setTimeout(resolve, 300));
return mockResources.find(r => r.id === id) || null;
},
// Reservations
getReservations: async (resourceId?: string): Promise<Reservation[]> => {
await new Promise(resolve => setTimeout(resolve, 500));
if (resourceId) {
return mockReservations.filter(r => r.resourceId === resourceId);
}
return mockReservations;
},
createReservation: async (data: CreateReservationRequest): Promise<Reservation> => {
await new Promise(resolve => setTimeout(resolve, 800));
const resource = mockResources.find(r => r.id === data.resourceId);
if (!resource) {
throw new Error('Resource not found');
}
const newReservation: Reservation = {
id: Date.now().toString(),
resourceId: data.resourceId,
resource,
userId: 'current-user', // Will come from authentication
startTime: data.startTime,
endTime: data.endTime,
title: data.title,
description: data.description,
status: 'pending',
createdAt: new Date(),
};
mockReservations.push(newReservation);
console.log('New reservation created:', newReservation);
console.log('All reservations now:', mockReservations);
return newReservation;
},
updateReservationStatus: async (id: string, status: 'confirmed' | 'cancelled'): Promise<Reservation> => {
await new Promise(resolve => setTimeout(resolve, 500));
const reservation = mockReservations.find(r => r.id === id);
if (!reservation) {
throw new Error('Reservation not found');
}
reservation.status = status;
return reservation;
},
};

View File

@ -0,0 +1,133 @@
import { Resource, Reservation, CreateReservationRequest } from '../types';
export interface LibreBookingAPIConfig {
baseURL: string;
apiKey?: string;
timeout?: number;
}
class LibreBookingAPIClient {
private config: LibreBookingAPIConfig;
private baseUrl: string;
constructor(config: LibreBookingAPIConfig) {
this.config = config;
this.baseUrl = config.baseURL.replace(/\/$/, ''); // Remove trailing slash
}
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
};
if (this.config.apiKey) {
headers['Authorization'] = `Bearer ${this.config.apiKey}`;
}
try {
const response = await fetch(url, {
...options,
headers,
signal: AbortSignal.timeout(this.config.timeout || 10000),
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
return response.text() as unknown as T;
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`Network error: ${error.message}`);
}
throw error;
}
}
// Resources
async getResources(): Promise<Resource[]> {
return this.makeRequest<Resource[]>('/api/resources');
}
async getResource(id: string): Promise<Resource> {
return this.makeRequest<Resource>(`/api/resources/${id}`);
}
// Reservations
async getReservations(resourceId?: string): Promise<Reservation[]> {
const params = resourceId ? `?resourceId=${resourceId}` : '';
return this.makeRequest<Reservation[]>(`/api/reservations${params}`);
}
async getReservation(id: string): Promise<Reservation> {
return this.makeRequest<Reservation>(`/api/reservations/${id}`);
}
async createReservation(data: CreateReservationRequest): Promise<Reservation> {
return this.makeRequest<Reservation>('/api/reservations', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateReservationStatus(
id: string,
status: 'confirmed' | 'cancelled'
): Promise<Reservation> {
return this.makeRequest<Reservation>(`/api/reservations/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
}
async cancelReservation(id: string): Promise<Reservation> {
return this.makeRequest<Reservation>(`/api/reservations/${id}/cancel`, {
method: 'POST',
});
}
// Users (if needed for admin functions)
async getUsers(): Promise<any[]> {
return this.makeRequest<any[]>('/api/users');
}
async getUserReservations(userId: string): Promise<Reservation[]> {
return this.makeRequest<Reservation[]>(`/api/users/${userId}/reservations`);
}
}
// Factory function to create API client with environment variables
export function createLibreBookingClient(
config?: Partial<LibreBookingAPIConfig>
): LibreBookingAPIClient {
const defaultConfig: LibreBookingAPIConfig = {
baseURL: process.env.REACT_APP_LIBREBOOKING_API_URL || 'http://localhost:8080',
apiKey: process.env.REACT_APP_LIBREBOOKING_API_KEY,
timeout: 10000,
};
const finalConfig = { ...defaultConfig, ...config };
return new LibreBookingAPIClient(finalConfig);
}
// Example of how to integrate with the existing mock API
export function integrateWithMockAPI(mockAPI: any) {
return {
getResources: mockAPI.getResources,
getResource: mockAPI.getResource,
getReservations: mockAPI.getReservations,
createReservation: mockAPI.createReservation,
updateReservationStatus: mockAPI.updateReservationStatus,
};
}

38
src/types/index.ts Normal file
View File

@ -0,0 +1,38 @@
export interface Resource {
id: string;
name: string;
description?: string;
location?: string;
capacity?: number;
imageUrl?: string;
amenities?: string[];
isAvailable: boolean;
}
export interface Reservation {
id: string;
resourceId: string;
resource: Resource;
userId: string;
startTime: Date;
endTime: Date;
title: string;
description?: string;
status: 'pending' | 'confirmed' | 'cancelled';
createdAt: Date;
}
export interface User {
id: string;
name: string;
email: string;
role: 'user' | 'admin';
}
export interface CreateReservationRequest {
resourceId: string;
startTime: Date;
endTime: Date;
title: string;
description?: string;
}