Update
This commit is contained in:
parent
941bc7e5e4
commit
bf4136b8dc
4
.env.example
Normal file
4
.env.example
Normal 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
142
README.md
@ -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 can’t go back!**
|
||||
3. Open [http://localhost:3000](http://localhost:3000) in your browser
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
### Development Mode
|
||||
The application runs with mock data by default for development and testing.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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
97
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
48
src/App.css
48
src/App.css
@ -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;
|
||||
}
|
||||
|
||||
39
src/App.tsx
39
src/App.tsx
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
74
src/components/ErrorBoundary.tsx
Normal file
74
src/components/ErrorBoundary.tsx
Normal 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
218
src/components/Layout.css
Normal 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
112
src/components/Layout.tsx
Normal 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;
|
||||
217
src/pages/AdminDashboard.css
Normal file
217
src/pages/AdminDashboard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
226
src/pages/AdminDashboard.tsx
Normal file
226
src/pages/AdminDashboard.tsx
Normal 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;
|
||||
142
src/pages/CreateReservation.css
Normal file
142
src/pages/CreateReservation.css
Normal 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;
|
||||
}
|
||||
}
|
||||
189
src/pages/CreateReservation.tsx
Normal file
189
src/pages/CreateReservation.tsx
Normal 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;
|
||||
213
src/pages/ResourceCalendar.css
Normal file
213
src/pages/ResourceCalendar.css
Normal 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;
|
||||
}
|
||||
}
|
||||
195
src/pages/ResourceCalendar.tsx
Normal file
195
src/pages/ResourceCalendar.tsx
Normal 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
189
src/pages/ResourcesList.css
Normal 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
113
src/pages/ResourcesList.tsx
Normal 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
361
src/pages/UserDashboard.css
Normal 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
282
src/pages/UserDashboard.tsx
Normal 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
131
src/services/api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
133
src/services/librebooking-api.ts
Normal file
133
src/services/librebooking-api.ts
Normal 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
38
src/types/index.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user