| 1 | import { useEffect, useMemo, useState } from "react";
|
|---|
| 2 | import { AnimatePresence, motion } from "framer-motion";
|
|---|
| 3 | import toast, { Toaster } from "react-hot-toast";
|
|---|
| 4 | import SearchRoomsPage from "./pages/SearchRoomsPage.jsx";
|
|---|
| 5 | import CreateReservationPage from "./pages/CreateReservationPage.jsx";
|
|---|
| 6 | import ApprovalsPage from "./pages/ApprovalsPage.jsx";
|
|---|
| 7 | import ReportsPage from "./pages/ReportsPage.jsx";
|
|---|
| 8 | import AdminPanelPage from "./pages/AdminPanelPage.jsx";
|
|---|
| 9 | import {
|
|---|
| 10 | Activity,
|
|---|
| 11 | ArrowRight,
|
|---|
| 12 | BarChart3,
|
|---|
| 13 | Boxes,
|
|---|
| 14 | CalendarCheck,
|
|---|
| 15 | CheckCircle2,
|
|---|
| 16 | ClipboardCheck,
|
|---|
| 17 | Database,
|
|---|
| 18 | DoorOpen,
|
|---|
| 19 | LogOut,
|
|---|
| 20 | PlusCircle,
|
|---|
| 21 | Search,
|
|---|
| 22 | ShieldCheck,
|
|---|
| 23 | Sparkles,
|
|---|
| 24 | UserRound,
|
|---|
| 25 | Users,
|
|---|
| 26 | } from "lucide-react";
|
|---|
| 27 |
|
|---|
| 28 | import {
|
|---|
| 29 | getDashboardStats,
|
|---|
| 30 | getHealth,
|
|---|
| 31 | loginUser,
|
|---|
| 32 | registerUser,
|
|---|
| 33 | } from "./api.js";
|
|---|
| 34 |
|
|---|
| 35 | function App() {
|
|---|
| 36 | const [activeUser, setActiveUser] = useState(() => {
|
|---|
| 37 | try {
|
|---|
| 38 | const savedUser = localStorage.getItem("activeUser");
|
|---|
| 39 | return savedUser ? JSON.parse(savedUser) : null;
|
|---|
| 40 | } catch {
|
|---|
| 41 | localStorage.removeItem("activeUser");
|
|---|
| 42 | return null;
|
|---|
| 43 | }
|
|---|
| 44 | });
|
|---|
| 45 |
|
|---|
| 46 | const [authView, setAuthView] = useState(() => {
|
|---|
| 47 | return localStorage.getItem("authView") || "landing";
|
|---|
| 48 | });
|
|---|
| 49 |
|
|---|
| 50 | const [activePage, setActivePage] = useState(() => {
|
|---|
| 51 | return localStorage.getItem("activePage") || "dashboard";
|
|---|
| 52 | });
|
|---|
| 53 |
|
|---|
| 54 | function changeAuthView(view) {
|
|---|
| 55 | localStorage.setItem("authView", view);
|
|---|
| 56 | setAuthView(view);
|
|---|
| 57 | }
|
|---|
| 58 |
|
|---|
| 59 | function handleLogin(user) {
|
|---|
| 60 | localStorage.setItem("activeUser", JSON.stringify(user));
|
|---|
| 61 | localStorage.setItem("activePage", "dashboard");
|
|---|
| 62 | localStorage.removeItem("authView");
|
|---|
| 63 |
|
|---|
| 64 | setActiveUser(user);
|
|---|
| 65 | setActivePage("dashboard");
|
|---|
| 66 |
|
|---|
| 67 | toast.success(`Logged in as ${user.fullName}`);
|
|---|
| 68 | }
|
|---|
| 69 |
|
|---|
| 70 | function handleLogout() {
|
|---|
| 71 | localStorage.removeItem("activeUser");
|
|---|
| 72 | localStorage.removeItem("activePage");
|
|---|
| 73 | localStorage.setItem("authView", "landing");
|
|---|
| 74 |
|
|---|
| 75 | setActiveUser(null);
|
|---|
| 76 | setAuthView("landing");
|
|---|
| 77 | setActivePage("dashboard");
|
|---|
| 78 | }
|
|---|
| 79 |
|
|---|
| 80 | useEffect(() => {
|
|---|
| 81 | if (activeUser) {
|
|---|
| 82 | localStorage.setItem("activePage", activePage);
|
|---|
| 83 | }
|
|---|
| 84 | }, [activePage, activeUser]);
|
|---|
| 85 |
|
|---|
| 86 | if (!activeUser) {
|
|---|
| 87 | return (
|
|---|
| 88 | <>
|
|---|
| 89 | <Toaster position="top-right" />
|
|---|
| 90 | <AnimatePresence mode="wait">
|
|---|
| 91 | {authView === "landing" && (
|
|---|
| 92 | <PageMotion key="landing">
|
|---|
| 93 | <LandingScreen
|
|---|
| 94 | onLoginClick={() => changeAuthView("login")}
|
|---|
| 95 | onRegisterClick={() => changeAuthView("register")}
|
|---|
| 96 | />
|
|---|
| 97 | </PageMotion>
|
|---|
| 98 | )}
|
|---|
| 99 |
|
|---|
| 100 | {authView === "login" && (
|
|---|
| 101 | <PageMotion key="login">
|
|---|
| 102 | <LoginScreen
|
|---|
| 103 | onLogin={handleLogin}
|
|---|
| 104 | onBack={() => changeAuthView("landing")}
|
|---|
| 105 | onRegisterClick={() => changeAuthView("register")}
|
|---|
| 106 | />
|
|---|
| 107 | </PageMotion>
|
|---|
| 108 | )}
|
|---|
| 109 |
|
|---|
| 110 | {authView === "register" && (
|
|---|
| 111 | <PageMotion key="register">
|
|---|
| 112 | <RegisterScreen
|
|---|
| 113 | onBack={() => changeAuthView("landing")}
|
|---|
| 114 | onLoginClick={() => changeAuthView("login")}
|
|---|
| 115 | onRegistered={handleLogin}
|
|---|
| 116 | />
|
|---|
| 117 | </PageMotion>
|
|---|
| 118 | )}
|
|---|
| 119 | </AnimatePresence>
|
|---|
| 120 | </>
|
|---|
| 121 | );
|
|---|
| 122 | }
|
|---|
| 123 |
|
|---|
| 124 | return (
|
|---|
| 125 | <>
|
|---|
| 126 | <Toaster position="top-right" />
|
|---|
| 127 | <AppShell
|
|---|
| 128 | activeUser={activeUser}
|
|---|
| 129 | activePage={activePage}
|
|---|
| 130 | setActivePage={setActivePage}
|
|---|
| 131 | onLogout={handleLogout}
|
|---|
| 132 | >
|
|---|
| 133 | <AnimatePresence mode="wait">
|
|---|
| 134 | {activePage === "dashboard" && (
|
|---|
| 135 | <PageMotion key="dashboard">
|
|---|
| 136 | <Dashboard activeUser={activeUser} setActivePage={setActivePage} />
|
|---|
| 137 | </PageMotion>
|
|---|
| 138 | )}
|
|---|
| 139 |
|
|---|
| 140 | {activePage === "search" && (
|
|---|
| 141 | <PageMotion key="search">
|
|---|
| 142 | <SearchRoomsPage />
|
|---|
| 143 | </PageMotion>
|
|---|
| 144 | )}
|
|---|
| 145 |
|
|---|
| 146 | {activePage === "create" && (
|
|---|
| 147 | <PageMotion key="create">
|
|---|
| 148 | <CreateReservationPage activeUser={activeUser} />
|
|---|
| 149 | </PageMotion>
|
|---|
| 150 | )}
|
|---|
| 151 |
|
|---|
| 152 | {activePage === "approvals" && (
|
|---|
| 153 | <PageMotion key="approvals">
|
|---|
| 154 | <ApprovalsPage activeUser={activeUser} />
|
|---|
| 155 | </PageMotion>
|
|---|
| 156 | )}
|
|---|
| 157 |
|
|---|
| 158 | {activePage === "reports" && (
|
|---|
| 159 | <PageMotion key="reports">
|
|---|
| 160 | <ReportsPage />
|
|---|
| 161 | </PageMotion>
|
|---|
| 162 | )}
|
|---|
| 163 |
|
|---|
| 164 | {activePage === "admin" && (
|
|---|
| 165 | <PageMotion key="admin">
|
|---|
| 166 | <AdminPanelPage activeUser={activeUser} />
|
|---|
| 167 | </PageMotion>
|
|---|
| 168 | )}
|
|---|
| 169 | </AnimatePresence>
|
|---|
| 170 | </AppShell>
|
|---|
| 171 | </>
|
|---|
| 172 | );
|
|---|
| 173 | }
|
|---|
| 174 |
|
|---|
| 175 | function LandingScreen({ onLoginClick, onRegisterClick }) {
|
|---|
| 176 | return (
|
|---|
| 177 | <div className="landing-page">
|
|---|
| 178 | <div className="landing-orb orb-one" />
|
|---|
| 179 | <div className="landing-orb orb-two" />
|
|---|
| 180 | <div className="landing-orb orb-three" />
|
|---|
| 181 |
|
|---|
| 182 | <header className="landing-header">
|
|---|
| 183 | <button
|
|---|
| 184 | className="landing-brand logo-home-button"
|
|---|
| 185 | type="button"
|
|---|
| 186 | onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
|---|
| 187 | title="Go to home"
|
|---|
| 188 | >
|
|---|
| 189 | <img src="/logo.png" alt="Room Reservation logo" />
|
|---|
| 190 | </button>
|
|---|
| 191 |
|
|---|
| 192 | <div className="landing-header-actions">
|
|---|
| 193 | <button className="ghost-button" onClick={onLoginClick}>
|
|---|
| 194 | Sign in
|
|---|
| 195 | </button>
|
|---|
| 196 | <button className="landing-small-primary" onClick={onRegisterClick}>
|
|---|
| 197 | Create account
|
|---|
| 198 | </button>
|
|---|
| 199 | </div>
|
|---|
| 200 | </header>
|
|---|
| 201 |
|
|---|
| 202 | <main className="landing-main">
|
|---|
| 203 | <motion.div
|
|---|
| 204 | className="landing-hero-content"
|
|---|
| 205 | initial={{ opacity: 0, y: 26 }}
|
|---|
| 206 | animate={{ opacity: 1, y: 0 }}
|
|---|
| 207 | transition={{ duration: 0.55 }}
|
|---|
| 208 | >
|
|---|
| 209 | <div className="landing-eyebrow">
|
|---|
| 210 | <Sparkles size={16} />
|
|---|
| 211 | Room Management Platform
|
|---|
| 212 | </div>
|
|---|
| 213 |
|
|---|
| 214 | <h1>Smart Room Reservation System</h1>
|
|---|
| 215 |
|
|---|
| 216 | <p>
|
|---|
| 217 | Search available rooms, create reservation requests, manage approvals,
|
|---|
| 218 | and monitor resource usage through live PostgreSQL reports.
|
|---|
| 219 | </p>
|
|---|
| 220 |
|
|---|
| 221 | <div className="landing-actions">
|
|---|
| 222 | <button className="landing-primary" onClick={onLoginClick}>
|
|---|
| 223 | Sign in
|
|---|
| 224 | <ArrowRight size={20} />
|
|---|
| 225 | </button>
|
|---|
| 226 |
|
|---|
| 227 | <button className="landing-secondary" onClick={onRegisterClick}>
|
|---|
| 228 | Create account
|
|---|
| 229 | <UserRound size={19} />
|
|---|
| 230 | </button>
|
|---|
| 231 | </div>
|
|---|
| 232 | </motion.div>
|
|---|
| 233 |
|
|---|
| 234 | <motion.div
|
|---|
| 235 | className="landing-preview-card"
|
|---|
| 236 | initial={{ opacity: 0, x: 36, scale: 0.96 }}
|
|---|
| 237 | animate={{ opacity: 1, x: 0, scale: 1 }}
|
|---|
| 238 | transition={{ duration: 0.65, delay: 0.15 }}
|
|---|
| 239 | >
|
|---|
| 240 | <div className="preview-topbar">
|
|---|
| 241 | <span />
|
|---|
| 242 | <span />
|
|---|
| 243 | <span />
|
|---|
| 244 | </div>
|
|---|
| 245 |
|
|---|
| 246 | <div className="preview-header">
|
|---|
| 247 | <div>
|
|---|
| 248 | <span>Live dashboard</span>
|
|---|
| 249 | <h3>Reservation Control Center</h3>
|
|---|
| 250 | </div>
|
|---|
| 251 |
|
|---|
| 252 | <div className="preview-live">
|
|---|
| 253 | <span className="status-dot" />
|
|---|
| 254 | Online
|
|---|
| 255 | </div>
|
|---|
| 256 | </div>
|
|---|
| 257 |
|
|---|
| 258 | <div className="preview-stats">
|
|---|
| 259 | <div>
|
|---|
| 260 | <DoorOpen size={22} />
|
|---|
| 261 | <strong>Rooms</strong>
|
|---|
| 262 | <span>Availability search</span>
|
|---|
| 263 | </div>
|
|---|
| 264 | <div>
|
|---|
| 265 | <ClipboardCheck size={22} />
|
|---|
| 266 | <strong>Approvals</strong>
|
|---|
| 267 | <span>Role-based decisions</span>
|
|---|
| 268 | </div>
|
|---|
| 269 | <div>
|
|---|
| 270 | <BarChart3 size={22} />
|
|---|
| 271 | <strong>Reports</strong>
|
|---|
| 272 | <span>Advanced analytics</span>
|
|---|
| 273 | </div>
|
|---|
| 274 | </div>
|
|---|
| 275 |
|
|---|
| 276 | <div className="preview-flow">
|
|---|
| 277 | <div className="flow-item">
|
|---|
| 278 | <CheckCircle2 size={18} />
|
|---|
| 279 | Search available rooms
|
|---|
| 280 | </div>
|
|---|
| 281 | <div className="flow-item">
|
|---|
| 282 | <CheckCircle2 size={18} />
|
|---|
| 283 | Create pending reservation
|
|---|
| 284 | </div>
|
|---|
| 285 | <div className="flow-item">
|
|---|
| 286 | <CheckCircle2 size={18} />
|
|---|
| 287 | Approve through stored function
|
|---|
| 288 | </div>
|
|---|
| 289 | </div>
|
|---|
| 290 | </motion.div>
|
|---|
| 291 | </main>
|
|---|
| 292 | </div>
|
|---|
| 293 | );
|
|---|
| 294 | }
|
|---|
| 295 |
|
|---|
| 296 | function LoginScreen({ onLogin, onBack, onRegisterClick }) {
|
|---|
| 297 | const [form, setForm] = useState({
|
|---|
| 298 | identifier: "",
|
|---|
| 299 | password: "",
|
|---|
| 300 | });
|
|---|
| 301 |
|
|---|
| 302 | const [loading, setLoading] = useState(false);
|
|---|
| 303 |
|
|---|
| 304 | function updateField(field, value) {
|
|---|
| 305 | setForm((prev) => ({ ...prev, [field]: value }));
|
|---|
| 306 | }
|
|---|
| 307 |
|
|---|
| 308 | async function submit(e) {
|
|---|
| 309 | e.preventDefault();
|
|---|
| 310 |
|
|---|
| 311 | if (!form.identifier.trim()) {
|
|---|
| 312 | toast.error("Username or email is required.");
|
|---|
| 313 | return;
|
|---|
| 314 | }
|
|---|
| 315 |
|
|---|
| 316 | if (!form.password.trim()) {
|
|---|
| 317 | toast.error("Password is required.");
|
|---|
| 318 | return;
|
|---|
| 319 | }
|
|---|
| 320 |
|
|---|
| 321 | setLoading(true);
|
|---|
| 322 |
|
|---|
| 323 | try {
|
|---|
| 324 | const user = await loginUser({
|
|---|
| 325 | identifier: form.identifier,
|
|---|
| 326 | password: form.password,
|
|---|
| 327 | });
|
|---|
| 328 |
|
|---|
| 329 | onLogin(user);
|
|---|
| 330 | } catch (err) {
|
|---|
| 331 | toast.error(err.message);
|
|---|
| 332 | } finally {
|
|---|
| 333 | setLoading(false);
|
|---|
| 334 | }
|
|---|
| 335 | }
|
|---|
| 336 |
|
|---|
| 337 | return (
|
|---|
| 338 | <div className="auth-page">
|
|---|
| 339 | <div className="landing-orb orb-one" />
|
|---|
| 340 | <div className="landing-orb orb-two" />
|
|---|
| 341 | <div className="landing-orb orb-three" />
|
|---|
| 342 |
|
|---|
| 343 | <motion.div
|
|---|
| 344 | className="auth-card"
|
|---|
| 345 | initial={{ opacity: 0, y: 28, scale: 0.96 }}
|
|---|
| 346 | animate={{ opacity: 1, y: 0, scale: 1 }}
|
|---|
| 347 | transition={{ duration: 0.55, ease: "easeOut" }}
|
|---|
| 348 | >
|
|---|
| 349 | <button className="auth-back-button" onClick={onBack}>
|
|---|
| 350 | ← Back to home
|
|---|
| 351 | </button>
|
|---|
| 352 |
|
|---|
| 353 | <div className="auth-left-panel">
|
|---|
| 354 | <button
|
|---|
| 355 | className="auth-logo logo-home-button"
|
|---|
| 356 | type="button"
|
|---|
| 357 | onClick={onBack}
|
|---|
| 358 | title="Back to home"
|
|---|
| 359 | >
|
|---|
| 360 | <img src="/logo.png" alt="Room Reservation logo" />
|
|---|
| 361 | </button>
|
|---|
| 362 |
|
|---|
| 363 | <div className="auth-left-copy">
|
|---|
| 364 | <div className="landing-eyebrow">
|
|---|
| 365 | <ShieldCheck size={16} />
|
|---|
| 366 | Role-based access
|
|---|
| 367 | </div>
|
|---|
| 368 |
|
|---|
| 369 | <h2>Access the reservation workspace.</h2>
|
|---|
| 370 | <p>
|
|---|
| 371 | Continue to the reservation system with the permissions assigned
|
|---|
| 372 | to your user role. Manage reservations, approvals, reports and
|
|---|
| 373 | administrative modules.
|
|---|
| 374 | </p>
|
|---|
| 375 | </div>
|
|---|
| 376 | </div>
|
|---|
| 377 |
|
|---|
| 378 | <div className="auth-right-panel">
|
|---|
| 379 | <form className="login-form polished" onSubmit={submit}>
|
|---|
| 380 | <div className="auth-form-header">
|
|---|
| 381 | <div className="auth-icon-badge">
|
|---|
| 382 | <UserRound size={24} />
|
|---|
| 383 | </div>
|
|---|
| 384 |
|
|---|
| 385 | <div>
|
|---|
| 386 | <h1>Sign In</h1>
|
|---|
| 387 | <p>Enter your account details and continue with role-based access.</p>
|
|---|
| 388 | </div>
|
|---|
| 389 | </div>
|
|---|
| 390 |
|
|---|
| 391 | <label>
|
|---|
| 392 | Username or email
|
|---|
| 393 | <input
|
|---|
| 394 | value={form.identifier}
|
|---|
| 395 | onChange={(e) => updateField("identifier", e.target.value)}
|
|---|
| 396 | placeholder="Enter username or email"
|
|---|
| 397 | autoComplete="username"
|
|---|
| 398 | />
|
|---|
| 399 | </label>
|
|---|
| 400 |
|
|---|
| 401 | <label>
|
|---|
| 402 | Password
|
|---|
| 403 | <input
|
|---|
| 404 | type="password"
|
|---|
| 405 | value={form.password}
|
|---|
| 406 | onChange={(e) => updateField("password", e.target.value)}
|
|---|
| 407 | placeholder="Enter your password"
|
|---|
| 408 | autoComplete="current-password"
|
|---|
| 409 | />
|
|---|
| 410 | </label>
|
|---|
| 411 |
|
|---|
| 412 | <button className="primary full auth-submit" disabled={loading}>
|
|---|
| 413 | {loading ? "Signing in..." : "Sign in"}
|
|---|
| 414 | <ArrowRight size={18} />
|
|---|
| 415 | </button>
|
|---|
| 416 |
|
|---|
| 417 | <button className="auth-link-button" type="button" onClick={onRegisterClick}>
|
|---|
| 418 | Create account
|
|---|
| 419 | </button>
|
|---|
| 420 | </form>
|
|---|
| 421 | </div>
|
|---|
| 422 | </motion.div>
|
|---|
| 423 | </div>
|
|---|
| 424 | );
|
|---|
| 425 | }
|
|---|
| 426 |
|
|---|
| 427 | function RegisterScreen({ onBack, onLoginClick, onRegistered }) {
|
|---|
| 428 | const [form, setForm] = useState({
|
|---|
| 429 | fullName: "",
|
|---|
| 430 | email: "",
|
|---|
| 431 | username: "",
|
|---|
| 432 | password: "",
|
|---|
| 433 | confirmPassword: "",
|
|---|
| 434 | });
|
|---|
| 435 |
|
|---|
| 436 | const [loading, setLoading] = useState(false);
|
|---|
| 437 |
|
|---|
| 438 | function updateField(field, value) {
|
|---|
| 439 | setForm((prev) => ({
|
|---|
| 440 | ...prev,
|
|---|
| 441 | [field]: value,
|
|---|
| 442 | }));
|
|---|
| 443 | }
|
|---|
| 444 |
|
|---|
| 445 | async function submit(e) {
|
|---|
| 446 | e.preventDefault();
|
|---|
| 447 |
|
|---|
| 448 | if (!form.fullName.trim()) {
|
|---|
| 449 | toast.error("Full name is required.");
|
|---|
| 450 | return;
|
|---|
| 451 | }
|
|---|
| 452 |
|
|---|
| 453 | if (!form.email.trim()) {
|
|---|
| 454 | toast.error("Email is required.");
|
|---|
| 455 | return;
|
|---|
| 456 | }
|
|---|
| 457 |
|
|---|
| 458 | if (!form.username.trim()) {
|
|---|
| 459 | toast.error("Username is required.");
|
|---|
| 460 | return;
|
|---|
| 461 | }
|
|---|
| 462 |
|
|---|
| 463 | if (!form.password.trim()) {
|
|---|
| 464 | toast.error("Password is required.");
|
|---|
| 465 | return;
|
|---|
| 466 | }
|
|---|
| 467 |
|
|---|
| 468 | if (form.password.length < 8) {
|
|---|
| 469 | toast.error("Password must contain at least 8 characters.");
|
|---|
| 470 | return;
|
|---|
| 471 | }
|
|---|
| 472 |
|
|---|
| 473 | if (form.password !== form.confirmPassword) {
|
|---|
| 474 | toast.error("Passwords do not match.");
|
|---|
| 475 | return;
|
|---|
| 476 | }
|
|---|
| 477 |
|
|---|
| 478 | setLoading(true);
|
|---|
| 479 |
|
|---|
| 480 | try {
|
|---|
| 481 | const user = await registerUser({
|
|---|
| 482 | fullName: form.fullName.trim(),
|
|---|
| 483 | email: form.email.trim(),
|
|---|
| 484 | username: form.username.trim(),
|
|---|
| 485 | password: form.password,
|
|---|
| 486 | });
|
|---|
| 487 |
|
|---|
| 488 | toast.success("Account created successfully.");
|
|---|
| 489 | onRegistered(user);
|
|---|
| 490 | } catch (err) {
|
|---|
| 491 | toast.error(err.message);
|
|---|
| 492 | } finally {
|
|---|
| 493 | setLoading(false);
|
|---|
| 494 | }
|
|---|
| 495 | }
|
|---|
| 496 |
|
|---|
| 497 | return (
|
|---|
| 498 | <div className="register-page">
|
|---|
| 499 | <div className="landing-orb orb-one" />
|
|---|
| 500 | <div className="landing-orb orb-two" />
|
|---|
| 501 | <div className="landing-orb orb-three" />
|
|---|
| 502 |
|
|---|
| 503 | <motion.div
|
|---|
| 504 | className="register-card"
|
|---|
| 505 | initial={{ opacity: 0, y: 28, scale: 0.96 }}
|
|---|
| 506 | animate={{ opacity: 1, y: 0, scale: 1 }}
|
|---|
| 507 | transition={{ duration: 0.5, ease: "easeOut" }}
|
|---|
| 508 | >
|
|---|
| 509 | <button className="auth-back-button" type="button" onClick={onBack}>
|
|---|
| 510 | ← Back to home
|
|---|
| 511 | </button>
|
|---|
| 512 |
|
|---|
| 513 | <div className="register-left">
|
|---|
| 514 | <button
|
|---|
| 515 | className="register-logo-button logo-home-button"
|
|---|
| 516 | type="button"
|
|---|
| 517 | onClick={onBack}
|
|---|
| 518 | title="Back to home"
|
|---|
| 519 | >
|
|---|
| 520 | <img src="/logo.png" alt="Room Reservation logo" />
|
|---|
| 521 | </button>
|
|---|
| 522 |
|
|---|
| 523 | <div>
|
|---|
| 524 | <div className="landing-eyebrow">
|
|---|
| 525 | <UserRound size={16} />
|
|---|
| 526 | Account creation
|
|---|
| 527 | </div>
|
|---|
| 528 |
|
|---|
| 529 | <h1>Create your account</h1>
|
|---|
| 530 |
|
|---|
| 531 | <p>
|
|---|
| 532 | Create a standard user account to submit room and equipment reservation requests.
|
|---|
| 533 | Administrative permissions are assigned separately by the system administrator.
|
|---|
| 534 | </p>
|
|---|
| 535 | </div>
|
|---|
| 536 | </div>
|
|---|
| 537 |
|
|---|
| 538 | <form className="register-form" onSubmit={submit}>
|
|---|
| 539 | <label>
|
|---|
| 540 | Full name
|
|---|
| 541 | <input
|
|---|
| 542 | value={form.fullName}
|
|---|
| 543 | onChange={(e) => updateField("fullName", e.target.value)}
|
|---|
| 544 | placeholder="Enter your full name"
|
|---|
| 545 | autoComplete="name"
|
|---|
| 546 | />
|
|---|
| 547 | </label>
|
|---|
| 548 |
|
|---|
| 549 | <label>
|
|---|
| 550 | Email
|
|---|
| 551 | <input
|
|---|
| 552 | type="email"
|
|---|
| 553 | value={form.email}
|
|---|
| 554 | onChange={(e) => updateField("email", e.target.value)}
|
|---|
| 555 | placeholder="example@finki.ukim.mk"
|
|---|
| 556 | autoComplete="email"
|
|---|
| 557 | />
|
|---|
| 558 | </label>
|
|---|
| 559 |
|
|---|
| 560 | <label>
|
|---|
| 561 | Username
|
|---|
| 562 | <input
|
|---|
| 563 | value={form.username}
|
|---|
| 564 | onChange={(e) => updateField("username", e.target.value)}
|
|---|
| 565 | placeholder="example_user"
|
|---|
| 566 | autoComplete="username"
|
|---|
| 567 | />
|
|---|
| 568 | </label>
|
|---|
| 569 |
|
|---|
| 570 | <label>
|
|---|
| 571 | Password
|
|---|
| 572 | <input
|
|---|
| 573 | type="password"
|
|---|
| 574 | value={form.password}
|
|---|
| 575 | onChange={(e) => updateField("password", e.target.value)}
|
|---|
| 576 | placeholder="At least 8 characters"
|
|---|
| 577 | autoComplete="new-password"
|
|---|
| 578 | />
|
|---|
| 579 | </label>
|
|---|
| 580 |
|
|---|
| 581 | <label>
|
|---|
| 582 | Confirm password
|
|---|
| 583 | <input
|
|---|
| 584 | type="password"
|
|---|
| 585 | value={form.confirmPassword}
|
|---|
| 586 | onChange={(e) => updateField("confirmPassword", e.target.value)}
|
|---|
| 587 | placeholder="Repeat password"
|
|---|
| 588 | autoComplete="new-password"
|
|---|
| 589 | />
|
|---|
| 590 | </label>
|
|---|
| 591 |
|
|---|
| 592 | <button className="landing-primary full" type="submit" disabled={loading}>
|
|---|
| 593 | {loading ? "Creating account..." : "Create account"}
|
|---|
| 594 | <ArrowRight size={19} />
|
|---|
| 595 | </button>
|
|---|
| 596 |
|
|---|
| 597 | <button className="auth-link-button" type="button" onClick={onLoginClick}>
|
|---|
| 598 | Already have an account? Sign in
|
|---|
| 599 | </button>
|
|---|
| 600 | </form>
|
|---|
| 601 | </motion.div>
|
|---|
| 602 | </div>
|
|---|
| 603 | );
|
|---|
| 604 | }
|
|---|
| 605 |
|
|---|
| 606 | function AppShell({ activeUser, activePage, setActivePage, onLogout, children }) {
|
|---|
| 607 | const items = useMemo(() => {
|
|---|
| 608 | const base = [
|
|---|
| 609 | { key: "dashboard", label: "Dashboard", icon: Activity, roles: ["regular", "approver", "admin"] },
|
|---|
| 610 | { key: "search", label: "Search Rooms", icon: Search, roles: ["regular", "approver", "admin"] },
|
|---|
| 611 | { key: "create", label: "Create Reservation", icon: PlusCircle, roles: ["regular", "approver", "admin"] },
|
|---|
| 612 | { key: "approvals", label: "Approvals", icon: ClipboardCheck, roles: ["approver", "admin"] },
|
|---|
| 613 | { key: "reports", label: "Reports", icon: BarChart3, roles: ["regular", "approver", "admin"] },
|
|---|
| 614 | { key: "admin", label: "Admin Panel", icon: ShieldCheck, roles: ["admin"] },
|
|---|
| 615 | ];
|
|---|
| 616 |
|
|---|
| 617 | return base.filter((item) => item.roles.includes(activeUser.role));
|
|---|
| 618 | }, [activeUser.role]);
|
|---|
| 619 |
|
|---|
| 620 | const activeItem = items.find((item) => item.key === activePage);
|
|---|
| 621 |
|
|---|
| 622 | useEffect(() => {
|
|---|
| 623 | const pageIsAllowed = items.some((item) => item.key === activePage);
|
|---|
| 624 |
|
|---|
| 625 | if (!pageIsAllowed) {
|
|---|
| 626 | localStorage.setItem("activePage", "dashboard");
|
|---|
| 627 | setActivePage("dashboard");
|
|---|
| 628 | }
|
|---|
| 629 | }, [items, activePage, setActivePage]);
|
|---|
| 630 |
|
|---|
| 631 | return (
|
|---|
| 632 | <div className="app-shell">
|
|---|
| 633 | <aside className="sidebar">
|
|---|
| 634 | <button
|
|---|
| 635 | className="brand brand-only-logo logo-home-button"
|
|---|
| 636 | type="button"
|
|---|
| 637 | onClick={() => setActivePage("dashboard")}
|
|---|
| 638 | title="Go to dashboard"
|
|---|
| 639 | >
|
|---|
| 640 | <div className="brand-logo">
|
|---|
| 641 | <img src="/logo.png" alt="Room Reservation logo" />
|
|---|
| 642 | </div>
|
|---|
| 643 | </button>
|
|---|
| 644 |
|
|---|
| 645 | <nav className="nav-menu">
|
|---|
| 646 | {items.map((item) => {
|
|---|
| 647 | const Icon = item.icon;
|
|---|
| 648 |
|
|---|
| 649 | return (
|
|---|
| 650 | <button
|
|---|
| 651 | key={item.key}
|
|---|
| 652 | className={activePage === item.key ? "active" : ""}
|
|---|
| 653 | onClick={() => setActivePage(item.key)}
|
|---|
| 654 | >
|
|---|
| 655 | <Icon size={19} />
|
|---|
| 656 | <span>{item.label}</span>
|
|---|
| 657 | </button>
|
|---|
| 658 | );
|
|---|
| 659 | })}
|
|---|
| 660 | </nav>
|
|---|
| 661 |
|
|---|
| 662 | <div className="sidebar-footer">
|
|---|
| 663 | <div className="user-card">
|
|---|
| 664 | <div className="avatar small">{getInitials(activeUser.fullName)}</div>
|
|---|
| 665 |
|
|---|
| 666 | <div>
|
|---|
| 667 | <strong>{activeUser.fullName}</strong>
|
|---|
| 668 | <span>{activeUser.role}</span>
|
|---|
| 669 | </div>
|
|---|
| 670 |
|
|---|
| 671 | <button className="icon-button" onClick={onLogout} title="Logout">
|
|---|
| 672 | <LogOut size={17} />
|
|---|
| 673 | </button>
|
|---|
| 674 | </div>
|
|---|
| 675 | </div>
|
|---|
| 676 | </aside>
|
|---|
| 677 |
|
|---|
| 678 | <main className="main-area">
|
|---|
| 679 | <header className="topbar">
|
|---|
| 680 | <div>
|
|---|
| 681 | <h2>{activeItem?.label || "Room Reservation"}</h2>
|
|---|
| 682 | </div>
|
|---|
| 683 |
|
|---|
| 684 | <div className="topbar-actions">
|
|---|
| 685 | <div className="status-pill">
|
|---|
| 686 | <span className="status-dot" />
|
|---|
| 687 | Live database
|
|---|
| 688 | </div>
|
|---|
| 689 |
|
|---|
| 690 | <div className="top-user">
|
|---|
| 691 | <UserRound size={18} />
|
|---|
| 692 | <div>
|
|---|
| 693 | <strong>{activeUser.fullName}</strong>
|
|---|
| 694 | <span>{activeUser.role}</span>
|
|---|
| 695 | </div>
|
|---|
| 696 | </div>
|
|---|
| 697 | </div>
|
|---|
| 698 | </header>
|
|---|
| 699 |
|
|---|
| 700 | {children}
|
|---|
| 701 | </main>
|
|---|
| 702 | </div>
|
|---|
| 703 | );
|
|---|
| 704 | }
|
|---|
| 705 |
|
|---|
| 706 | function Dashboard({ activeUser, setActivePage }) {
|
|---|
| 707 | const [stats, setStats] = useState(null);
|
|---|
| 708 | const [databaseOnline, setDatabaseOnline] = useState(false);
|
|---|
| 709 | const [loading, setLoading] = useState(true);
|
|---|
| 710 |
|
|---|
| 711 | useEffect(() => {
|
|---|
| 712 | let mounted = true;
|
|---|
| 713 |
|
|---|
| 714 | async function loadDashboard() {
|
|---|
| 715 | setLoading(true);
|
|---|
| 716 |
|
|---|
| 717 | try {
|
|---|
| 718 | const [statsResponse, healthResponse] = await Promise.all([
|
|---|
| 719 | getDashboardStats(),
|
|---|
| 720 | getHealth().catch(() => null),
|
|---|
| 721 | ]);
|
|---|
| 722 |
|
|---|
| 723 | if (!mounted) {
|
|---|
| 724 | return;
|
|---|
| 725 | }
|
|---|
| 726 |
|
|---|
| 727 | setStats(statsResponse);
|
|---|
| 728 | setDatabaseOnline(Boolean(healthResponse));
|
|---|
| 729 | } catch (err) {
|
|---|
| 730 | if (!mounted) {
|
|---|
| 731 | return;
|
|---|
| 732 | }
|
|---|
| 733 |
|
|---|
| 734 | setDatabaseOnline(false);
|
|---|
| 735 | toast.error(err.message || "Dashboard data could not be loaded.");
|
|---|
| 736 | } finally {
|
|---|
| 737 | if (mounted) {
|
|---|
| 738 | setLoading(false);
|
|---|
| 739 | }
|
|---|
| 740 | }
|
|---|
| 741 | }
|
|---|
| 742 |
|
|---|
| 743 | loadDashboard();
|
|---|
| 744 |
|
|---|
| 745 | return () => {
|
|---|
| 746 | mounted = false;
|
|---|
| 747 | };
|
|---|
| 748 | }, []);
|
|---|
| 749 |
|
|---|
| 750 | const safeStats = stats || {
|
|---|
| 751 | totalRooms: 0,
|
|---|
| 752 | totalEquipmentTypes: 0,
|
|---|
| 753 | totalUsers: 0,
|
|---|
| 754 | pendingReservations: 0,
|
|---|
| 755 | approvedReservations: 0,
|
|---|
| 756 | };
|
|---|
| 757 |
|
|---|
| 758 | const firstName = activeUser?.fullName?.split(" ")?.[0] || "User";
|
|---|
| 759 | const role = activeUser?.role || "regular";
|
|---|
| 760 |
|
|---|
| 761 | const statCards = [
|
|---|
| 762 | {
|
|---|
| 763 | label: "Rooms",
|
|---|
| 764 | value: safeStats.totalRooms,
|
|---|
| 765 | meta: "Available spaces",
|
|---|
| 766 | icon: DoorOpen,
|
|---|
| 767 | tone: "blue",
|
|---|
| 768 | },
|
|---|
| 769 | {
|
|---|
| 770 | label: "Equipment",
|
|---|
| 771 | value: safeStats.totalEquipmentTypes,
|
|---|
| 772 | meta: "Resource types",
|
|---|
| 773 | icon: Boxes,
|
|---|
| 774 | tone: "purple",
|
|---|
| 775 | },
|
|---|
| 776 | {
|
|---|
| 777 | label: "Users",
|
|---|
| 778 | value: safeStats.totalUsers,
|
|---|
| 779 | meta: "System accounts",
|
|---|
| 780 | icon: Users,
|
|---|
| 781 | tone: "cyan",
|
|---|
| 782 | },
|
|---|
| 783 | {
|
|---|
| 784 | label: "Pending",
|
|---|
| 785 | value: safeStats.pendingReservations,
|
|---|
| 786 | meta: "Awaiting decision",
|
|---|
| 787 | icon: CalendarCheck,
|
|---|
| 788 | tone: "orange",
|
|---|
| 789 | },
|
|---|
| 790 | {
|
|---|
| 791 | label: "Approved",
|
|---|
| 792 | value: safeStats.approvedReservations,
|
|---|
| 793 | meta: "Confirmed bookings",
|
|---|
| 794 | icon: CheckCircle2,
|
|---|
| 795 | tone: "green",
|
|---|
| 796 | },
|
|---|
| 797 | ];
|
|---|
| 798 |
|
|---|
| 799 | const quickActions = [
|
|---|
| 800 | {
|
|---|
| 801 | page: "search",
|
|---|
| 802 | title: "Search available rooms",
|
|---|
| 803 | description: "Check capacity, type, equipment and time overlap.",
|
|---|
| 804 | icon: Search,
|
|---|
| 805 | },
|
|---|
| 806 | {
|
|---|
| 807 | page: "create",
|
|---|
| 808 | title: "Create reservation",
|
|---|
| 809 | description: "Submit a room or equipment request for approval.",
|
|---|
| 810 | icon: PlusCircle,
|
|---|
| 811 | },
|
|---|
| 812 | ...(role === "approver" || role === "admin"
|
|---|
| 813 | ? [
|
|---|
| 814 | {
|
|---|
| 815 | page: "approvals",
|
|---|
| 816 | title: "Review approvals",
|
|---|
| 817 | description: "Approve or reject pending reservation requests.",
|
|---|
| 818 | icon: ClipboardCheck,
|
|---|
| 819 | },
|
|---|
| 820 | ]
|
|---|
| 821 | : []),
|
|---|
| 822 | {
|
|---|
| 823 | page: "reports",
|
|---|
| 824 | title: "Open reports",
|
|---|
| 825 | description: "View advanced SQL analytical reports.",
|
|---|
| 826 | icon: BarChart3,
|
|---|
| 827 | },
|
|---|
| 828 | ...(role === "admin"
|
|---|
| 829 | ? [
|
|---|
| 830 | {
|
|---|
| 831 | page: "admin",
|
|---|
| 832 | title: "Admin panel",
|
|---|
| 833 | description: "Manage administrative project modules.",
|
|---|
| 834 | icon: ShieldCheck,
|
|---|
| 835 | },
|
|---|
| 836 | ]
|
|---|
| 837 | : []),
|
|---|
| 838 | ];
|
|---|
| 839 |
|
|---|
| 840 | return (
|
|---|
| 841 | <motion.div
|
|---|
| 842 | className="dashboard-page premium-dashboard"
|
|---|
| 843 | initial={{ opacity: 0, y: 18 }}
|
|---|
| 844 | animate={{ opacity: 1, y: 0 }}
|
|---|
| 845 | transition={{ duration: 0.45, ease: "easeOut" }}
|
|---|
| 846 | >
|
|---|
| 847 | <section className="dashboard-hero-compact">
|
|---|
| 848 | <div>
|
|---|
| 849 | <div className="dashboard-eyebrow">
|
|---|
| 850 | <Sparkles size={16} />
|
|---|
| 851 | System overview
|
|---|
| 852 | </div>
|
|---|
| 853 |
|
|---|
| 854 | <h1>Welcome back, {firstName}</h1>
|
|---|
| 855 |
|
|---|
| 856 | <p>
|
|---|
| 857 | Manage reservations, approvals, room availability and analytical
|
|---|
| 858 | PostgreSQL reports from one control center.
|
|---|
| 859 | </p>
|
|---|
| 860 | </div>
|
|---|
| 861 | </section>
|
|---|
| 862 |
|
|---|
| 863 | <section className="dashboard-stat-grid">
|
|---|
| 864 | {statCards.map((card) => {
|
|---|
| 865 | const Icon = card.icon;
|
|---|
| 866 |
|
|---|
| 867 | return (
|
|---|
| 868 | <motion.article
|
|---|
| 869 | className={`dashboard-stat-card ${card.tone}`}
|
|---|
| 870 | key={card.label}
|
|---|
| 871 | whileHover={{ y: -6, scale: 1.01 }}
|
|---|
| 872 | transition={{ duration: 0.18 }}
|
|---|
| 873 | >
|
|---|
| 874 | <div className="stat-icon-wrap">
|
|---|
| 875 | <Icon size={24} />
|
|---|
| 876 | </div>
|
|---|
| 877 |
|
|---|
| 878 | <div>
|
|---|
| 879 | <span>{card.label}</span>
|
|---|
| 880 | <strong>{loading ? "—" : card.value}</strong>
|
|---|
| 881 | <p>{card.meta}</p>
|
|---|
| 882 | </div>
|
|---|
| 883 | </motion.article>
|
|---|
| 884 | );
|
|---|
| 885 | })}
|
|---|
| 886 | </section>
|
|---|
| 887 |
|
|---|
| 888 | <section className="dashboard-main-grid">
|
|---|
| 889 | <article className="dashboard-panel quick-panel">
|
|---|
| 890 | <div className="panel-heading">
|
|---|
| 891 | <div>
|
|---|
| 892 | <h2>Quick actions</h2>
|
|---|
| 893 | <p>Frequently used reservation workflows.</p>
|
|---|
| 894 | </div>
|
|---|
| 895 | </div>
|
|---|
| 896 |
|
|---|
| 897 | <div className="quick-action-grid">
|
|---|
| 898 | {quickActions.map((action) => {
|
|---|
| 899 | const Icon = action.icon;
|
|---|
| 900 |
|
|---|
| 901 | return (
|
|---|
| 902 | <button
|
|---|
| 903 | key={action.page}
|
|---|
| 904 | type="button"
|
|---|
| 905 | onClick={() => setActivePage(action.page)}
|
|---|
| 906 | >
|
|---|
| 907 | <Icon size={24} />
|
|---|
| 908 | <strong>{action.title}</strong>
|
|---|
| 909 | <span>{action.description}</span>
|
|---|
| 910 | </button>
|
|---|
| 911 | );
|
|---|
| 912 | })}
|
|---|
| 913 | </div>
|
|---|
| 914 | </article>
|
|---|
| 915 |
|
|---|
| 916 | <aside className="dashboard-panel status-panel">
|
|---|
| 917 | <div className="panel-heading">
|
|---|
| 918 | <div>
|
|---|
| 919 | <h2>System status</h2>
|
|---|
| 920 | <p>Current connection and access information.</p>
|
|---|
| 921 | </div>
|
|---|
| 922 | </div>
|
|---|
| 923 |
|
|---|
| 924 | <div className="status-stack">
|
|---|
| 925 | <div className="status-card database">
|
|---|
| 926 | <Database size={28} />
|
|---|
| 927 | <div>
|
|---|
| 928 | <span>Status</span>
|
|---|
| 929 | <strong>{databaseOnline ? "CONNECTED" : "CHECK CONNECTION"}</strong>
|
|---|
| 930 | <p>Live project database · {loading ? "—" : safeStats.totalRooms} rooms loaded</p>
|
|---|
| 931 | </div>
|
|---|
| 932 | </div>
|
|---|
| 933 |
|
|---|
| 934 | <div className="status-card role">
|
|---|
| 935 | <ShieldCheck size={28} />
|
|---|
| 936 | <div>
|
|---|
| 937 | <span>Active role</span>
|
|---|
| 938 | <strong>{role.toUpperCase()}</strong>
|
|---|
| 939 | <p>Your available modules depend on this role.</p>
|
|---|
| 940 | </div>
|
|---|
| 941 | </div>
|
|---|
| 942 | </div>
|
|---|
| 943 | </aside>
|
|---|
| 944 | </section>
|
|---|
| 945 | </motion.div>
|
|---|
| 946 | );
|
|---|
| 947 | }
|
|---|
| 948 |
|
|---|
| 949 | function PageMotion({ children }) {
|
|---|
| 950 | return (
|
|---|
| 951 | <motion.div
|
|---|
| 952 | initial={{ opacity: 0, y: 18, filter: "blur(6px)" }}
|
|---|
| 953 | animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
|---|
| 954 | exit={{ opacity: 0, y: -12, filter: "blur(4px)" }}
|
|---|
| 955 | transition={{ duration: 0.28, ease: "easeOut" }}
|
|---|
| 956 | >
|
|---|
| 957 | {children}
|
|---|
| 958 | </motion.div>
|
|---|
| 959 | );
|
|---|
| 960 | }
|
|---|
| 961 |
|
|---|
| 962 | function getInitials(name) {
|
|---|
| 963 | if (!name) {
|
|---|
| 964 | return "U";
|
|---|
| 965 | }
|
|---|
| 966 |
|
|---|
| 967 | return name
|
|---|
| 968 | .split(" ")
|
|---|
| 969 | .map((part) => part[0])
|
|---|
| 970 | .slice(0, 2)
|
|---|
| 971 | .join("")
|
|---|
| 972 | .toUpperCase();
|
|---|
| 973 | }
|
|---|
| 974 |
|
|---|
| 975 | export default App; |
|---|