From bf4136b8dc5890572a430be117c238eb1377f8a9 Mon Sep 17 00:00:00 2001 From: Joshua Schmucker Date: Fri, 30 Jan 2026 14:32:53 +0100 Subject: [PATCH] Update --- .env.example | 4 + README.md | 142 +++++++++--- package-lock.json | 97 +++++++++ package.json | 3 + src/App.css | 48 ++-- src/App.tsx | 39 ++-- src/components/ErrorBoundary.tsx | 74 +++++++ src/components/Layout.css | 218 +++++++++++++++++++ src/components/Layout.tsx | 112 ++++++++++ src/pages/AdminDashboard.css | 217 +++++++++++++++++++ src/pages/AdminDashboard.tsx | 226 +++++++++++++++++++ src/pages/CreateReservation.css | 142 ++++++++++++ src/pages/CreateReservation.tsx | 189 ++++++++++++++++ src/pages/ResourceCalendar.css | 213 ++++++++++++++++++ src/pages/ResourceCalendar.tsx | 195 +++++++++++++++++ src/pages/ResourcesList.css | 189 ++++++++++++++++ src/pages/ResourcesList.tsx | 113 ++++++++++ src/pages/UserDashboard.css | 361 +++++++++++++++++++++++++++++++ src/pages/UserDashboard.tsx | 282 ++++++++++++++++++++++++ src/services/api.ts | 131 +++++++++++ src/services/librebooking-api.ts | 133 ++++++++++++ src/types/index.ts | 38 ++++ 22 files changed, 3090 insertions(+), 76 deletions(-) create mode 100644 .env.example create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/components/Layout.css create mode 100644 src/components/Layout.tsx create mode 100644 src/pages/AdminDashboard.css create mode 100644 src/pages/AdminDashboard.tsx create mode 100644 src/pages/CreateReservation.css create mode 100644 src/pages/CreateReservation.tsx create mode 100644 src/pages/ResourceCalendar.css create mode 100644 src/pages/ResourceCalendar.tsx create mode 100644 src/pages/ResourcesList.css create mode 100644 src/pages/ResourcesList.tsx create mode 100644 src/pages/UserDashboard.css create mode 100644 src/pages/UserDashboard.tsx create mode 100644 src/services/api.ts create mode 100644 src/services/librebooking-api.ts create mode 100644 src/types/index.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8906da2 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index b87cb00..5dd8f04 100644 --- a/README.md +++ b/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. diff --git a/package-lock.json b/package-lock.json index 3fc4c7c..3bd7d4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6aecf75..e852d6e 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/App.css b/src/App.css index 74b5e05..0393278 100644 --- a/src/App.css +++ b/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; } diff --git a/src/App.tsx b/src/App.tsx index a53698a..dbc31bc 100644 --- a/src/App.tsx +++ b/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 ( -
-
- logo -

- Edit src/App.tsx and save to reload. -

- - Learn React - -
-
+ + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); } diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..fec51a7 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -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 ( +
+

Something went wrong

+

The application encountered an error. Please try refreshing the page.

+
+ Error details +
+              {this.state.error?.stack}
+            
+
+ +
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/src/components/Layout.css b/src/components/Layout.css new file mode 100644 index 0000000..8314fed --- /dev/null +++ b/src/components/Layout.css @@ -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; + } +} \ No newline at end of file diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..cf1efa2 --- /dev/null +++ b/src/components/Layout.tsx @@ -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 = ({ 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 ( +
+ +
+ {children} +
+
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/src/pages/AdminDashboard.css b/src/pages/AdminDashboard.css new file mode 100644 index 0000000..f15fd42 --- /dev/null +++ b/src/pages/AdminDashboard.css @@ -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; + } +} \ No newline at end of file diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx new file mode 100644 index 0000000..de709e9 --- /dev/null +++ b/src/pages/AdminDashboard.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [processing, setProcessing] = useState(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
Loading admin dashboard...
; + } + + return ( +
+
+

Admin Dashboard

+

Manage resource reservations

+
+ + {error &&
{error}
} + +
+
+

{pendingReservations.length}

+

Pending Reservations

+
+
+

{confirmedReservations.length}

+

Confirmed Reservations

+
+
+

{cancelledReservations.length}

+

Cancelled Reservations

+
+
+ +
+ {pendingReservations.length > 0 && ( +
+

Pending Reservations

+
+ {pendingReservations.map(reservation => ( + handleConfirmReservation(reservation.id)} + onCancel={() => handleCancelReservation(reservation.id)} + processing={processing === reservation.id} + /> + ))} +
+
+ )} + +
+

All Reservations

+
+ {reservations.length === 0 ? ( +

No reservations found

+ ) : ( + reservations.map(reservation => ( + handleConfirmReservation(reservation.id) : undefined} + onCancel={reservation.status === 'pending' ? () => handleCancelReservation(reservation.id) : undefined} + processing={processing === reservation.id} + /> + )) + )} +
+
+
+
+ ); +}; + +interface ReservationCardProps { + reservation: Reservation; + onConfirm?: () => void; + onCancel?: () => void; + processing: boolean; +} + +const ReservationCard: React.FC = ({ + reservation, + onConfirm, + onCancel, + processing +}) => { + const statusColors = { + pending: '#f39c12', + confirmed: '#27ae60', + cancelled: '#e74c3c' + }; + + return ( +
+
+

{reservation.title}

+ + {reservation.status} + +
+ +
+
+ Resource: {reservation.resource.name} +
+
+ Location: {reservation.resource.location} +
+
+ Date: {format(new Date(reservation.startTime), 'MMMM d, yyyy')} +
+
+ Time: {format(new Date(reservation.startTime), 'h:mm a')} - {format(new Date(reservation.endTime), 'h:mm a')} +
+ {reservation.description && ( +
+ Description: {reservation.description} +
+ )} +
+ Created: {format(new Date(reservation.createdAt), 'MMM d, yyyy h:mm a')} +
+
+ + {(onConfirm || onCancel) && ( +
+ {onConfirm && ( + + )} + {onCancel && ( + + )} +
+ )} +
+ ); +}; + +export default AdminDashboard; \ No newline at end of file diff --git a/src/pages/CreateReservation.css b/src/pages/CreateReservation.css new file mode 100644 index 0000000..676b63a --- /dev/null +++ b/src/pages/CreateReservation.css @@ -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; + } +} \ No newline at end of file diff --git a/src/pages/CreateReservation.tsx b/src/pages/CreateReservation.tsx new file mode 100644 index 0000000..81e7945 --- /dev/null +++ b/src/pages/CreateReservation.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const preselectedResourceId = searchParams.get('resourceId'); + + const [formData, setFormData] = useState({ + 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
Loading resources...
; + } + + if (resources.length === 0) { + return ( +
+

No Available Resources

+

There are currently no available resources to book.

+
+ ); + } + + return ( +
+
+

Create New Reservation

+
+ + {error &&
{error}
} + +
+
+ + +
+ +
+
+ + handleInputChange('startTime', new Date(e.target.value))} + required + /> +
+ +
+ + handleInputChange('endTime', new Date(e.target.value))} + required + /> +
+
+ +
+ + handleInputChange('title', e.target.value)} + placeholder="Meeting title or purpose" + required + /> +
+ +
+ +