feat: complete UI redesign with dark mode, sidebar navigation, and modern design system

Implemented comprehensive UI overhaul with three-layer architecture:

Layer 1 - Theme System:
- CSS variables for light/dark themes (theme.css)
- Theme composable with light/dark/auto mode (useTheme.ts)
- Sidebar state management composable (useSidebar.ts)
- Refactored main.css to use CSS variables throughout

Layer 2 - Core Components:
- AppSidebar with collapsible navigation (desktop) and overlay (mobile)
- CollapsibleSection reusable component for expandable cards
- Restructured App.vue with new sidebar layout
- Integrated Lucide icons library (lucide-vue-next)

Layer 3 - Views & Components:
- Updated all 14 views with CSS variables and responsive design
- Replaced inline SVG with Lucide icon components
- Added collapsible sections to Dashboard, Admin pages, UserProfile
- Updated 3 shared components (BookingForm, SpaceCalendar, AttachmentsList)

Features:
- Dark/light/auto theme with persistent preference
- Collapsible sidebar (icons-only on desktop, overlay on mobile)
- Consistent color palette using CSS variables
- Full responsive design across all pages
- Modern minimalist aesthetic with Indigo accent color

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-11 21:27:05 +00:00
parent 9c2846cf00
commit 0bf3e6a7e2
28 changed files with 1960 additions and 1641 deletions

View File

@@ -1,142 +0,0 @@
# Implementation Summary: Timezone Fix + Per-Space Settings
## Overview
Fixed timezone display issues and added per-space scheduling settings to the space booking system.
## Changes Implemented
### Part A: Frontend Timezone Fix ✅
**File: `frontend/src/utils/datetime.ts`**
- Added `ensureUTC()` function to handle naive datetime strings from backend
- Applied `ensureUTC()` to all `new Date()` calls in:
- `formatDateTime()`
- `formatTime()`
- `formatDateTimeWithTZ()`
- `isoToLocalDateTime()`
**File: `frontend/src/views/Dashboard.vue`**
- Imported `ensureUTC` from utils
- Applied to booking date comparisons in `upcomingBookings` computed property
**Impact:** MyBookings now correctly shows times in user's timezone (e.g., 10:00-17:00 Bucharest instead of 08:00-15:00 UTC).
---
### Part B: Per-Space Scheduling Settings (Database) ✅
**File: `backend/app/models/space.py`**
- Added 4 nullable columns:
- `working_hours_start` (Integer)
- `working_hours_end` (Integer)
- `min_duration_minutes` (Integer)
- `max_duration_minutes` (Integer)
**File: `backend/app/schemas/space.py`**
- Added 4 optional fields to `SpaceBase` and `SpaceResponse`
**File: `backend/migrations/004_add_per_space_settings.sql`**
- Created migration (already applied to database)
**Impact:** Spaces can now override global settings. NULL = use global default.
---
### Part C: Timezone-Aware Validation ✅
**File: `backend/app/services/booking_service.py`**
- Imported `Space` model and timezone utilities
- Added `user_timezone` parameter to `validate_booking_rules()`
- Load per-space settings with fallback to global
- Convert UTC times to user timezone for working hours validation
- Fix max bookings per day to use local date boundaries
**File: `backend/app/api/bookings.py`**
Updated 6 endpoints to pass `user_timezone`:
1. **POST /bookings (create)** - Line ~251
2. **POST /bookings/recurring** - Line ~376
- Also fixed: Now converts local times to UTC before storage
3. **PUT /bookings/{id} (update)** - Line ~503
4. **PUT /admin/.../approve** - Line ~682
- Uses booking owner's timezone
5. **PUT /admin/.../update** - Line ~865
- Uses booking owner's timezone
6. **PUT /admin/.../reschedule** - Line ~1013
- Uses booking owner's timezone
**Impact:**
- Working hours validation now uses user's local time (9:00 Bucharest is valid, not rejected as 7:00 UTC)
- Per-space settings are respected
- Recurring bookings now store correct UTC times
---
### Part D: Admin UI for Per-Space Settings ✅
**File: `frontend/src/views/Admin.vue`**
- Added form section "Per-Space Scheduling Settings"
- Added 4 input fields with placeholders indicating global defaults
- Updated `formData` reactive object
- Updated `startEdit()` to load space settings
- Updated `resetForm()` to clear settings
- Added CSS for form-section-header, form-row, and help-text
**File: `frontend/src/types/index.ts`**
- Extended `Space` interface with 4 optional fields
**Impact:** Admins can now configure per-space scheduling rules via UI.
---
## Testing Checklist
### Timezone Display
- [ ] User with `Europe/Bucharest` timezone sees correct local times in MyBookings
- [ ] Booking created at 09:00 Bucharest shows as 09:00 (not 07:00)
### Working Hours Validation
- [ ] Booking at 09:00 Bucharest (07:00 UTC) is accepted (not rejected by 8-20 rule)
- [ ] User sees error message with correct timezone-aware hours
### Per-Space Settings
- [ ] Create space with custom working hours (e.g., 10-18)
- [ ] Booking outside custom hours is rejected
- [ ] Space without settings uses global defaults
- [ ] Admin UI displays and saves per-space settings correctly
### Recurring Bookings
- [ ] Recurring bookings now store correct UTC times
- [ ] Bookings created for multiple occurrences have consistent timezone handling
---
## Files Modified
| # | File | Changes |
|---|------|---------|
| 1 | `frontend/src/utils/datetime.ts` | Added `ensureUTC()` + applied to 4 functions |
| 2 | `frontend/src/views/Dashboard.vue` | Import and use `ensureUTC` |
| 3 | `backend/app/models/space.py` | Added 4 nullable columns |
| 4 | `backend/app/schemas/space.py` | Added 4 optional fields |
| 5 | `backend/migrations/004_add_per_space_settings.sql` | DB migration (applied) |
| 6 | `backend/app/services/booking_service.py` | Timezone-aware validation |
| 7 | `backend/app/api/bookings.py` | Updated 6 callers + recurring fix |
| 8 | `frontend/src/views/Admin.vue` | Admin UI for per-space settings |
| 9 | `frontend/src/types/index.ts` | Extended Space interface |
---
## Known Limitations
1. **SQLite**: COMMENT ON COLUMN not supported (documented in migration file)
2. **Backward Compatibility**: Existing bookings created before fix may have incorrect times (data migration not included)
---
## Next Steps
1. Run frontend ESLint: `cd frontend && npx eslint src/utils/datetime.ts src/views/Dashboard.vue src/views/Admin.vue`
2. Run backend tests: `cd backend && pytest` (if tests exist)
3. Manual testing per checklist above
4. Consider data migration for existing bookings if needed

View File

@@ -15,6 +15,7 @@
"@fullcalendar/vue3": "^6.1.0", "@fullcalendar/vue3": "^6.1.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"lucide-vue-next": "^0.563.0",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.2.0" "vue-router": "^4.2.0"
@@ -2769,6 +2770,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lucide-vue-next": {
"version": "0.563.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.563.0.tgz",
"integrity": "sha512-zsE/lCKtmaa7bGfhSpN84br1K9YoQ5pCN+2oKWjQQG3Lo6ufUUKBuHSjNFI6RvUevxaajNXb8XwFUKeTXG3sIA==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@@ -17,6 +17,7 @@
"@fullcalendar/vue3": "^6.1.0", "@fullcalendar/vue3": "^6.1.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"lucide-vue-next": "^0.563.0",
"pinia": "^2.1.0", "pinia": "^2.1.0",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.2.0" "vue-router": "^4.2.0"

View File

@@ -1,34 +1,29 @@
<template> <template>
<div id="app"> <div id="app">
<header v-if="authStore.isAuthenticated" class="header"> <AppSidebar v-if="authStore.isAuthenticated" />
<div class="container">
<h1>Space Booking</h1>
<nav>
<router-link to="/dashboard">Dashboard</router-link>
<router-link to="/spaces">Spaces</router-link>
<router-link to="/my-bookings">My Bookings</router-link>
<router-link v-if="authStore.isAdmin" to="/admin">Spaces Admin</router-link>
<router-link v-if="authStore.isAdmin" to="/users">Users Admin</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/pending">Pending Requests</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/settings">Settings</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/reports">Reports</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/audit-log">Audit Log</router-link>
<div class="app-main" :class="{ 'with-sidebar': authStore.isAuthenticated, 'sidebar-collapsed': collapsed }">
<!-- Mobile header bar -->
<div v-if="authStore.isAuthenticated" class="mobile-header">
<button class="mobile-hamburger" @click="toggleMobile" aria-label="Open menu">
<Menu :size="22" />
</button>
<span class="mobile-title">Space Booking</span>
<div class="mobile-actions">
<!-- Notification Bell --> <!-- Notification Bell -->
<div class="notification-wrapper"> <div class="notification-wrapper">
<button @click="toggleNotifications" class="notification-bell" aria-label="Notifications"> <button @click="toggleNotifications" class="notification-bell" aria-label="Notifications">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <Bell :size="20" />
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path> <span v-if="unreadCount > 0" class="notification-badge">{{ unreadCount }}</span>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
<span v-if="unreadCount > 0" class="badge">{{ unreadCount }}</span>
</button> </button>
<!-- Notification Dropdown --> <!-- Notification Dropdown -->
<div v-if="showNotifications" class="notification-dropdown" ref="dropdownRef"> <div v-if="showNotifications" class="notification-dropdown" ref="dropdownRef">
<div class="notification-header"> <div class="notification-header">
<h3>Notifications</h3> <h3>Notifications</h3>
<button @click="closeNotifications" class="close-btn">&times;</button> <button @click="closeNotifications" class="close-btn">
<X :size="18" />
</button>
</div> </div>
<div v-if="loading" class="notification-loading">Loading...</div> <div v-if="loading" class="notification-loading">Loading...</div>
@@ -51,15 +46,14 @@
</div> </div>
</div> </div>
</div> </div>
<button @click="logout" class="btn-logout">Logout ({{ authStore.user?.email }})</button>
</nav>
</div> </div>
</header> </div>
<main class="main">
<main class="content">
<router-view /> <router-view />
</main> </main>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -67,10 +61,14 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { notificationsApi } from '@/services/api' import { notificationsApi } from '@/services/api'
import { useSidebar } from '@/composables/useSidebar'
import type { Notification } from '@/types' import type { Notification } from '@/types'
import AppSidebar from '@/components/AppSidebar.vue'
import { Menu, Bell, X } from 'lucide-vue-next'
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
const { collapsed, toggleMobile } = useSidebar()
const notifications = ref<Notification[]>([]) const notifications = ref<Notification[]>([])
const showNotifications = ref(false) const showNotifications = ref(false)
@@ -83,17 +81,11 @@ const unreadCount = computed(() => {
return notifications.value.filter((n) => !n.is_read).length return notifications.value.filter((n) => !n.is_read).length
}) })
const logout = () => {
authStore.logout()
router.push('/login')
}
const fetchNotifications = async () => { const fetchNotifications = async () => {
if (!authStore.isAuthenticated) return if (!authStore.isAuthenticated) return
try { try {
loading.value = true loading.value = true
// Get all notifications, sorted by created_at DESC (from API)
notifications.value = await notificationsApi.getAll() notifications.value = await notificationsApi.getAll()
} catch (error) { } catch (error) {
console.error('Failed to fetch notifications:', error) console.error('Failed to fetch notifications:', error)
@@ -114,18 +106,15 @@ const closeNotifications = () => {
} }
const handleNotificationClick = async (notification: Notification) => { const handleNotificationClick = async (notification: Notification) => {
// Mark as read
if (!notification.is_read) { if (!notification.is_read) {
try { try {
await notificationsApi.markAsRead(notification.id) await notificationsApi.markAsRead(notification.id)
// Update local state
notification.is_read = true notification.is_read = true
} catch (error) { } catch (error) {
console.error('Failed to mark notification as read:', error) console.error('Failed to mark notification as read:', error)
} }
} }
// Navigate to booking if available
if (notification.booking_id) { if (notification.booking_id) {
closeNotifications() closeNotifications()
router.push('/my-bookings') router.push('/my-bookings')
@@ -147,25 +136,20 @@ const formatTime = (dateStr: string): string => {
return date.toLocaleDateString() return date.toLocaleDateString()
} }
// Click outside to close
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if ( if (
dropdownRef.value && dropdownRef.value &&
!dropdownRef.value.contains(event.target as Node) && !dropdownRef.value.contains(target) &&
!(event.target as HTMLElement).closest('.notification-bell') !target.closest('.notification-bell')
) { ) {
closeNotifications() closeNotifications()
} }
} }
onMounted(() => { onMounted(() => {
// Initial fetch
fetchNotifications() fetchNotifications()
// Auto-refresh every 30 seconds
refreshInterval = window.setInterval(fetchNotifications, 30000) refreshInterval = window.setInterval(fetchNotifications, 30000)
// Add click outside listener
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
}) })
@@ -178,64 +162,68 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.header { /* Layout */
background: #2c3e50; .app-main {
color: white; min-height: 100vh;
padding: 1rem 0; transition: margin-left var(--transition-normal);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
} }
.container { .app-main.with-sidebar {
margin-left: var(--sidebar-width);
}
.app-main.with-sidebar.sidebar-collapsed {
margin-left: var(--sidebar-collapsed-width);
}
.content {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 0 1rem; padding: 2rem 1.5rem;
display: flex; }
justify-content: space-between;
/* Mobile header */
.mobile-header {
display: none;
position: sticky;
top: 0;
z-index: 50;
align-items: center; align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
} }
h1 { .mobile-hamburger {
margin: 0; background: transparent;
font-size: 1.5rem;
}
nav {
display: flex;
gap: 1.5rem;
align-items: center;
}
nav a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background 0.2s;
}
nav a:hover,
nav a.router-link-active {
background: rgba(255,255,255,0.1);
}
.btn-logout {
background: #e74c3c;
color: white;
border: none; border: none;
padding: 0.5rem 1rem; color: var(--color-text-primary);
border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; padding: 0.4rem;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast);
} }
.btn-logout:hover { .mobile-hamburger:hover {
background: #c0392b; background: var(--color-bg-secondary);
} }
.main { .mobile-title {
max-width: 1200px; font-size: 1.1rem;
margin: 2rem auto; font-weight: 700;
padding: 0 1rem; color: var(--color-text-primary);
flex: 1;
}
.mobile-actions {
display: flex;
align-items: center;
gap: 0.25rem;
} }
/* Notifications */ /* Notifications */
@@ -246,44 +234,47 @@ nav a.router-link-active {
.notification-bell { .notification-bell {
background: transparent; background: transparent;
border: none; border: none;
color: white; color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
padding: 0.5rem; padding: 0.4rem;
border-radius: 4px; border-radius: var(--radius-sm);
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: background 0.2s; transition: all var(--transition-fast);
} }
.notification-bell:hover { .notification-bell:hover {
background: rgba(255, 255, 255, 0.1); background: var(--color-bg-secondary);
color: var(--color-text-primary);
} }
.notification-bell .badge { .notification-badge {
position: absolute; position: absolute;
top: 2px; top: 0;
right: 2px; right: 0;
background: #e74c3c; background: var(--color-danger);
color: white; color: white;
border-radius: 10px; border-radius: 10px;
padding: 2px 6px; padding: 1px 5px;
font-size: 0.7rem; font-size: 0.65rem;
font-weight: bold; font-weight: bold;
min-width: 18px; min-width: 16px;
text-align: center; text-align: center;
line-height: 1.3;
} }
.notification-dropdown { .notification-dropdown {
position: absolute; position: absolute;
top: calc(100% + 10px); top: calc(100% + 8px);
right: 0; right: 0;
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-lg);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-lg);
border: 1px solid var(--color-border);
width: 360px; width: 360px;
max-height: 400px; max-height: 420px;
overflow: hidden; overflow: hidden;
z-index: 1000; z-index: 1000;
display: flex; display: flex;
@@ -294,42 +285,39 @@ nav a.router-link-active {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem 1.25rem;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--color-border-light);
background: #f8f9fa;
} }
.notification-header h3 { .notification-header h3 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
color: #2c3e50; color: var(--color-text-primary);
} }
.close-btn { .close-btn {
background: none; background: none;
border: none; border: none;
font-size: 1.5rem; color: var(--color-text-muted);
color: #7f8c8d;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0.25rem;
width: 24px;
height: 24px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 4px; border-radius: var(--radius-sm);
transition: all var(--transition-fast);
} }
.close-btn:hover { .close-btn:hover {
background: #e0e0e0; background: var(--color-bg-secondary);
color: #2c3e50; color: var(--color-text-primary);
} }
.notification-loading, .notification-loading,
.notification-empty { .notification-empty {
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
color: #7f8c8d; color: var(--color-text-muted);
} }
.notification-list { .notification-list {
@@ -338,28 +326,32 @@ nav a.router-link-active {
} }
.notification-item { .notification-item {
padding: 1rem; padding: 1rem 1.25rem;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--color-border-light);
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background var(--transition-fast);
} }
.notification-item:hover { .notification-item:hover {
background: #f8f9fa; background: var(--color-surface-hover);
}
.notification-item:last-child {
border-bottom: none;
} }
.notification-item.unread { .notification-item.unread {
background: #e8f4fd; background: var(--color-accent-light);
border-left: 3px solid #3498db; border-left: 3px solid var(--color-accent);
} }
.notification-item.unread:hover { .notification-item.unread:hover {
background: #d6ebfa; background: var(--color-surface-hover);
} }
.notification-title { .notification-title {
font-weight: 600; font-weight: 600;
color: #2c3e50; color: var(--color-text-primary);
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
@@ -369,21 +361,41 @@ nav a.router-link-active {
} }
.notification-message { .notification-message {
color: #555; color: var(--color-text-secondary);
font-size: 0.85rem; font-size: 0.85rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
line-height: 1.4; line-height: 1.4;
} }
.notification-time { .notification-time {
color: #95a5a6; color: var(--color-text-muted);
font-size: 0.75rem; font-size: 0.75rem;
} }
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.app-main.with-sidebar {
margin-left: 0;
}
.app-main.with-sidebar.sidebar-collapsed {
margin-left: 0;
}
.mobile-header {
display: flex;
}
.content {
padding: 1.25rem 1rem;
}
.notification-dropdown { .notification-dropdown {
width: 320px; position: fixed;
left: 1rem;
right: 1rem;
top: 60px;
width: auto;
} }
} }
</style> </style>

View File

@@ -9,8 +9,8 @@
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #2c3e50; color: var(--color-text-primary);
background: #f5f5f5; background: var(--color-bg-primary);
} }
#app { #app {
@@ -21,37 +21,37 @@ body {
.btn { .btn {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
transition: all 0.2s; transition: all var(--transition-fast);
} }
.btn-primary { .btn-primary {
background: #3498db; background: var(--color-accent);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background: #2980b9; background: var(--color-accent-hover);
} }
.btn-success { .btn-success {
background: #27ae60; background: var(--color-success);
color: white; color: white;
} }
.btn-success:hover { .btn-success:hover {
background: #229954; filter: brightness(0.9);
} }
.btn-danger { .btn-danger {
background: #e74c3c; background: var(--color-danger);
color: white; color: white;
} }
.btn-danger:hover { .btn-danger:hover {
background: #c0392b; filter: brightness(0.9);
} }
/* Forms */ /* Forms */
@@ -63,6 +63,7 @@ body {
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-weight: 500; font-weight: 500;
color: var(--color-text-primary);
} }
.form-group input, .form-group input,
@@ -70,57 +71,61 @@ body {
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 1rem; font-size: 1rem;
background: var(--color-surface);
color: var(--color-text-primary);
transition: border-color var(--transition-fast);
} }
.form-group input:focus, .form-group input:focus,
.form-group select:focus, .form-group select:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: #3498db; border-color: var(--color-accent);
} }
.error { .error {
color: #e74c3c; color: var(--color-danger);
font-size: 0.9rem; font-size: 0.9rem;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* Cards */ /* Cards */
.card { .card {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
} }
/* Status badges */ /* Status badges */
.badge { .badge {
display: inline-block; display: inline-block;
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
border-radius: 12px; border-radius: var(--radius-lg);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
} }
.badge-pending { .badge-pending {
background: #fff3cd; background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: #856404; color: var(--color-warning);
} }
.badge-approved { .badge-approved {
background: #d4edda; background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: #155724; color: var(--color-success);
} }
.badge-rejected { .badge-rejected {
background: #f8d7da; background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: #721c24; color: var(--color-danger);
} }
.badge-canceled { .badge-canceled {
background: #e2e3e5; background: var(--color-bg-tertiary);
color: #383d41; color: var(--color-text-secondary);
} }

View File

@@ -0,0 +1,69 @@
/* Design Tokens - Light Theme (default) */
:root {
/* Base colors */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f8f9fa;
--color-bg-tertiary: #f1f3f5;
--color-surface: #ffffff;
--color-surface-hover: #f8f9fa;
/* Text */
--color-text-primary: #1a1a2e;
--color-text-secondary: #6b7280;
--color-text-muted: #9ca3af;
/* Accent */
--color-accent: #6366f1;
--color-accent-hover: #4f46e5;
--color-accent-light: #eef2ff;
/* Semantic */
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--color-info: #3b82f6;
/* Borders */
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
/* Sidebar */
--sidebar-width: 260px;
--sidebar-collapsed-width: 68px;
--sidebar-bg: #1a1a2e;
--sidebar-text: #a1a1b5;
--sidebar-text-active: #ffffff;
--sidebar-hover-bg: rgba(255, 255, 255, 0.08);
/* Spacing */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
}
/* Dark Theme */
[data-theme="dark"] {
--color-bg-primary: #0f0f1a;
--color-bg-secondary: #1a1a2e;
--color-bg-tertiary: #232340;
--color-surface: #1a1a2e;
--color-surface-hover: #232340;
--color-text-primary: #e5e5ef;
--color-text-secondary: #9ca3af;
--color-text-muted: #6b7280;
--color-accent-light: #1e1b4b;
--color-border: #2d2d4a;
--color-border-light: #232340;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
}

View File

@@ -0,0 +1,320 @@
<template>
<aside class="sidebar" :class="{ collapsed, 'mobile-open': mobileOpen }">
<div class="sidebar-header">
<LayoutDashboard :size="24" class="sidebar-logo-icon" />
<span v-show="!collapsed" class="sidebar-title">Space Booking</span>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<span v-show="!collapsed" class="nav-section-label">Main</span>
<router-link
v-for="item in mainNav"
:key="item.to"
:to="item.to"
class="nav-link"
:class="{ active: isActive(item.to) }"
@click="closeMobile"
>
<component :is="item.icon" :size="20" class="nav-icon" />
<span v-show="!collapsed" class="nav-label">{{ item.label }}</span>
</router-link>
</div>
<div v-if="authStore.isAdmin" class="nav-section">
<span v-show="!collapsed" class="nav-section-label">Admin</span>
<router-link
v-for="item in adminNav"
:key="item.to"
:to="item.to"
class="nav-link"
:class="{ active: isActive(item.to) }"
@click="closeMobile"
>
<component :is="item.icon" :size="20" class="nav-icon" />
<span v-show="!collapsed" class="nav-label">{{ item.label }}</span>
</router-link>
</div>
</nav>
<div class="sidebar-footer">
<div v-show="!collapsed" class="user-info">
<div class="user-avatar">
{{ authStore.user?.email?.charAt(0).toUpperCase() }}
</div>
<span class="user-email">{{ authStore.user?.email }}</span>
</div>
<div class="footer-actions">
<button class="footer-btn" @click="toggleTheme" :title="themeTitle">
<Sun v-if="resolvedTheme === 'light'" :size="18" />
<Moon v-else-if="resolvedTheme === 'dark'" :size="18" />
</button>
<button class="footer-btn collapse-toggle desktop-only" @click="toggle" :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'">
<ChevronRight v-if="collapsed" :size="18" />
<ChevronLeft v-else :size="18" />
</button>
<button class="footer-btn logout-btn" @click="handleLogout" title="Logout">
<LogOut :size="18" />
</button>
</div>
</div>
</aside>
<div v-if="mobileOpen" class="sidebar-overlay" @click="closeMobile" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSidebar } from '@/composables/useSidebar'
import { useTheme } from '@/composables/useTheme'
import {
LayoutDashboard,
Building2,
CalendarDays,
User,
Settings2,
Users,
ClipboardCheck,
Sliders,
BarChart3,
ScrollText,
Sun,
Moon,
ChevronLeft,
ChevronRight,
LogOut
} from 'lucide-vue-next'
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const { collapsed, mobileOpen, toggle, closeMobile } = useSidebar()
const { theme, resolvedTheme, toggleTheme } = useTheme()
const themeTitle = computed(() => {
if (theme.value === 'light') return 'Switch to dark mode'
if (theme.value === 'dark') return 'Switch to auto mode'
return 'Switch to light mode'
})
const mainNav = [
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/spaces', icon: Building2, label: 'Spaces' },
{ to: '/my-bookings', icon: CalendarDays, label: 'My Bookings' },
{ to: '/profile', icon: User, label: 'Profile' },
]
const adminNav = [
{ to: '/admin', icon: Settings2, label: 'Spaces Admin' },
{ to: '/users', icon: Users, label: 'Users' },
{ to: '/admin/pending', icon: ClipboardCheck, label: 'Pending' },
{ to: '/admin/settings', icon: Sliders, label: 'Settings' },
{ to: '/admin/reports', icon: BarChart3, label: 'Reports' },
{ to: '/admin/audit-log', icon: ScrollText, label: 'Audit Log' },
]
const isActive = (path: string) => {
if (path === '/dashboard') return route.path === '/dashboard'
return route.path.startsWith(path)
}
const handleLogout = () => {
authStore.logout()
router.push('/login')
}
</script>
<style scoped>
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-width);
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
z-index: 100;
transition: width var(--transition-normal), transform var(--transition-normal);
overflow: hidden;
}
.sidebar.collapsed {
width: var(--sidebar-collapsed-width);
}
/* Header */
.sidebar-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
min-height: 60px;
}
.sidebar-logo-icon {
color: var(--color-accent);
flex-shrink: 0;
}
.sidebar-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--sidebar-text-active);
white-space: nowrap;
overflow: hidden;
}
/* Navigation */
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 0.75rem 0;
}
.nav-section {
margin-bottom: 0.5rem;
}
.nav-section-label {
display: block;
padding: 0.5rem 1.25rem 0.25rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--sidebar-text);
opacity: 0.6;
}
.nav-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1.25rem;
color: var(--sidebar-text);
text-decoration: none;
transition: all var(--transition-fast);
border-left: 3px solid transparent;
white-space: nowrap;
}
.nav-link:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-active);
}
.nav-link.active {
color: var(--sidebar-text-active);
background: var(--sidebar-hover-bg);
border-left-color: var(--color-accent);
}
.nav-icon {
flex-shrink: 0;
}
.nav-label {
overflow: hidden;
text-overflow: ellipsis;
}
/* Footer */
.sidebar-footer {
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding: 0.75rem 1rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.25rem;
margin-bottom: 0.5rem;
overflow: hidden;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-weight: 600;
flex-shrink: 0;
}
.user-email {
font-size: 0.8rem;
color: var(--sidebar-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.footer-btn {
background: transparent;
border: none;
color: var(--sidebar-text);
cursor: pointer;
padding: 0.5rem;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.footer-btn:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-active);
}
.logout-btn:hover {
color: var(--color-danger);
}
/* Sidebar overlay for mobile */
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
}
/* Mobile styles */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
width: var(--sidebar-width);
}
.sidebar.mobile-open {
transform: translateX(0);
}
.sidebar.collapsed {
width: var(--sidebar-width);
}
.desktop-only {
display: none;
}
}
</style>

View File

@@ -126,7 +126,7 @@ onMounted(() => {
.attachments-title { .attachments-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #111827; color: var(--color-text-primary);
margin: 0 0 12px 0; margin: 0 0 12px 0;
} }
@@ -134,19 +134,19 @@ onMounted(() => {
.error, .error,
.no-attachments { .no-attachments {
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
} }
.loading, .loading,
.no-attachments { .no-attachments {
background: #f3f4f6; background: var(--color-bg-tertiary);
color: #6b7280; color: var(--color-text-secondary);
} }
.error { .error {
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: #991b1b; color: var(--color-danger);
} }
.attachment-items { .attachment-items {
@@ -160,9 +160,9 @@ onMounted(() => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px; padding: 12px;
background: #f9fafb; background: var(--color-bg-secondary);
border: 1px solid #e5e7eb; border: 1px solid var(--color-border);
border-radius: 6px; border-radius: var(--radius-sm);
margin-bottom: 8px; margin-bottom: 8px;
gap: 12px; gap: 12px;
} }
@@ -189,7 +189,7 @@ onMounted(() => {
.attachment-name { .attachment-name {
font-size: 14px; font-size: 14px;
color: #3b82f6; color: var(--color-accent);
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
overflow: hidden; overflow: hidden;
@@ -203,23 +203,23 @@ onMounted(() => {
.attachment-meta { .attachment-meta {
font-size: 12px; font-size: 12px;
color: #6b7280; color: var(--color-text-secondary);
} }
.btn-delete { .btn-delete {
padding: 6px 12px; padding: 6px 12px;
background: white; background: var(--color-surface);
color: #ef4444; color: var(--color-danger);
border: 1px solid #ef4444; border: 1px solid var(--color-danger);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all var(--transition-fast);
flex-shrink: 0; flex-shrink: 0;
} }
.btn-delete:hover:not(:disabled) { .btn-delete:hover:not(:disabled) {
background: #fef2f2; background: color-mix(in srgb, var(--color-danger) 5%, transparent);
} }
.btn-delete:disabled { .btn-delete:disabled {

View File

@@ -437,7 +437,7 @@ onMounted(() => {
display: block; display: block;
margin-bottom: 6px; margin-bottom: 6px;
font-weight: 500; font-weight: 500;
color: #374151; color: var(--color-text-primary);
font-size: 14px; font-size: 14px;
} }
@@ -445,7 +445,7 @@ onMounted(() => {
display: block; display: block;
margin-bottom: 4px; margin-bottom: 4px;
font-weight: 400; font-weight: 400;
color: #6b7280; color: var(--color-text-secondary);
font-size: 12px; font-size: 12px;
} }
@@ -464,26 +464,28 @@ onMounted(() => {
.form-textarea { .form-textarea {
width: 100%; width: 100%;
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #d1d5db; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
font-family: inherit; font-family: inherit;
transition: border-color 0.2s; transition: border-color var(--transition-fast);
background: var(--color-surface);
color: var(--color-text-primary);
} }
.form-input:focus, .form-input:focus,
.form-textarea:focus { .form-textarea:focus {
outline: none; outline: none;
border-color: #3b82f6; border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
} }
.form-input-error { .form-input-error {
border-color: #ef4444; border-color: var(--color-danger);
} }
.form-input:disabled { .form-input:disabled {
background-color: #f3f4f6; background-color: var(--color-bg-tertiary);
cursor: not-allowed; cursor: not-allowed;
} }
@@ -494,24 +496,24 @@ onMounted(() => {
.form-error { .form-error {
display: block; display: block;
margin-top: 4px; margin-top: 4px;
color: #ef4444; color: var(--color-danger);
font-size: 13px; font-size: 13px;
} }
.api-error { .api-error {
padding: 12px; padding: 12px;
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: #991b1b; color: var(--color-danger);
border-radius: 4px; border-radius: var(--radius-sm);
margin-bottom: 16px; margin-bottom: 16px;
font-size: 14px; font-size: 14px;
} }
.success-message { .success-message {
padding: 12px; padding: 12px;
background: #d1fae5; background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: #065f46; color: var(--color-success);
border-radius: 4px; border-radius: var(--radius-sm);
margin-bottom: 16px; margin-bottom: 16px;
font-size: 14px; font-size: 14px;
} }
@@ -525,11 +527,11 @@ onMounted(() => {
.btn { .btn {
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all var(--transition-fast);
} }
.btn:disabled { .btn:disabled {
@@ -538,33 +540,34 @@ onMounted(() => {
} }
.btn-primary { .btn-primary {
background: #3b82f6; background: var(--color-accent);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: #2563eb; background: var(--color-accent-hover);
} }
.btn-secondary { .btn-secondary {
background: #6b7280; background: var(--color-text-secondary);
color: white; color: white;
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: #4b5563; background: var(--color-text-primary);
} }
.warning-banner { .warning-banner {
background: #fff3cd; background: color-mix(in srgb, var(--color-warning) 15%, transparent);
border: 1px solid #ffc107; border: 1px solid var(--color-warning);
border-radius: 4px; border-radius: var(--radius-sm);
padding: 12px; padding: 12px;
margin-bottom: 16px; margin-bottom: 16px;
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: flex-start; align-items: flex-start;
font-size: 14px; font-size: 14px;
color: var(--color-text-primary);
} }
.warning-icon { .warning-icon {

View File

@@ -0,0 +1,94 @@
<template>
<div class="collapsible-section">
<div class="section-header" @click="toggle" role="button" :aria-expanded="isOpen">
<component :is="icon" v-if="icon" :size="20" class="section-icon" />
<h3 class="section-title">{{ title }}</h3>
<div class="header-actions" @click.stop>
<slot name="header-actions" />
</div>
<ChevronDown class="chevron" :class="{ rotated: !isOpen }" :size="18" />
</div>
<div v-show="isOpen" class="section-content">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, type Component } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
const props = withDefaults(defineProps<{
title: string
icon?: Component
defaultOpen?: boolean
}>(), {
defaultOpen: true
})
const isOpen = ref(props.defaultOpen)
const toggle = () => {
isOpen.value = !isOpen.value
}
defineExpose({ isOpen, toggle })
</script>
<style scoped>
.collapsible-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
cursor: pointer;
user-select: none;
transition: background var(--transition-fast);
}
.section-header:hover {
background: var(--color-surface-hover);
}
.section-icon {
color: var(--color-accent);
flex-shrink: 0;
}
.section-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
flex: 1;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chevron {
color: var(--color-text-muted);
transition: transform var(--transition-normal);
flex-shrink: 0;
}
.chevron.rotated {
transform: rotate(-90deg);
}
.section-content {
padding: 1.25rem;
border-top: 1px solid var(--color-border-light);
}
</style>

View File

@@ -313,32 +313,32 @@ defineExpose({ refresh })
<style scoped> <style scoped>
.space-calendar { .space-calendar {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} }
.admin-notice { .admin-notice {
background: #e3f2fd; background: color-mix(in srgb, var(--color-info) 15%, transparent);
padding: 8px 16px; padding: 8px 16px;
margin-bottom: 16px; margin-bottom: 16px;
border-radius: 4px; border-radius: var(--radius-sm);
color: #1976d2; color: var(--color-info);
font-size: 14px; font-size: 14px;
} }
.error { .error {
padding: 12px; padding: 12px;
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: #991b1b; color: var(--color-danger);
border-radius: 4px; border-radius: var(--radius-sm);
margin-bottom: 16px; margin-bottom: 16px;
} }
.loading { .loading {
text-align: center; text-align: center;
color: #6b7280; color: var(--color-text-secondary);
padding: 24px; padding: 24px;
} }
@@ -357,23 +357,23 @@ defineExpose({ refresh })
} }
.modal-content { .modal-content {
background: white; background: var(--color-surface);
padding: 24px; padding: 24px;
border-radius: 8px; border-radius: var(--radius-md);
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-lg);
} }
.modal-content h3 { .modal-content h3 {
margin-top: 0; margin-top: 0;
margin-bottom: 16px; margin-bottom: 16px;
color: #1f2937; color: var(--color-text-primary);
} }
.modal-content p { .modal-content p {
margin-bottom: 20px; margin-bottom: 20px;
color: #6b7280; color: var(--color-text-secondary);
} }
.time-comparison { .time-comparison {
@@ -382,26 +382,27 @@ defineExpose({ refresh })
gap: 16px; gap: 16px;
margin: 20px 0; margin: 20px 0;
padding: 16px; padding: 16px;
background: #f9fafb; background: var(--color-bg-secondary);
border-radius: 4px; border-radius: var(--radius-sm);
} }
.old-time, .old-time,
.new-time { .new-time {
flex: 1; flex: 1;
font-size: 14px; font-size: 14px;
color: var(--color-text-secondary);
} }
.old-time strong, .old-time strong,
.new-time strong { .new-time strong {
color: #374151; color: var(--color-text-primary);
display: block; display: block;
margin-bottom: 4px; margin-bottom: 4px;
} }
.arrow { .arrow {
font-size: 24px; font-size: 24px;
color: #9ca3af; color: var(--color-text-muted);
} }
.modal-actions { .modal-actions {
@@ -412,40 +413,40 @@ defineExpose({ refresh })
} }
.btn-primary { .btn-primary {
background: #3b82f6; background: var(--color-accent);
color: white; color: white;
border: none; border: none;
padding: 10px 20px; padding: 10px 20px;
border-radius: 4px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
transition: background 0.2s; transition: background var(--transition-fast);
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: #2563eb; background: var(--color-accent-hover);
} }
.btn-primary:disabled { .btn-primary:disabled {
background: #93c5fd; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.btn-secondary { .btn-secondary {
background: #f3f4f6; background: var(--color-bg-tertiary);
color: #374151; color: var(--color-text-primary);
border: 1px solid #d1d5db; border: 1px solid var(--color-border);
padding: 10px 20px; padding: 10px 20px;
border-radius: 4px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
transition: background 0.2s; transition: background var(--transition-fast);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: #e5e7eb; background: var(--color-border);
} }
.btn-secondary:disabled { .btn-secondary:disabled {
@@ -459,28 +460,28 @@ defineExpose({ refresh })
} }
:deep(.fc-button) { :deep(.fc-button) {
background: #3b82f6; background: var(--color-accent);
border-color: #3b82f6; border-color: var(--color-accent);
text-transform: capitalize; text-transform: capitalize;
} }
:deep(.fc-button:hover) { :deep(.fc-button:hover) {
background: #2563eb; background: var(--color-accent-hover);
border-color: #2563eb; border-color: var(--color-accent-hover);
} }
:deep(.fc-button-active) { :deep(.fc-button-active) {
background: #1d4ed8 !important; background: var(--color-accent-hover) !important;
border-color: #1d4ed8 !important; border-color: var(--color-accent-hover) !important;
} }
:deep(.fc-daygrid-day-number) { :deep(.fc-daygrid-day-number) {
color: #374151; color: var(--color-text-primary);
font-weight: 500; font-weight: 500;
} }
:deep(.fc-col-header-cell-cushion) { :deep(.fc-col-header-cell-cushion) {
color: #374151; color: var(--color-text-primary);
font-weight: 600; font-weight: 600;
} }

View File

@@ -0,0 +1,43 @@
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
const STORAGE_KEY = 'space-booking-sidebar-collapsed'
const collapsed = ref(localStorage.getItem(STORAGE_KEY) === 'true')
const mobileOpen = ref(false)
export function useSidebar() {
const router = useRouter()
// Close mobile sidebar on route change
watch(() => router.currentRoute.value.path, () => {
mobileOpen.value = false
})
function toggle() {
collapsed.value = !collapsed.value
localStorage.setItem(STORAGE_KEY, String(collapsed.value))
}
function close() {
collapsed.value = true
localStorage.setItem(STORAGE_KEY, 'true')
}
function toggleMobile() {
mobileOpen.value = !mobileOpen.value
}
function closeMobile() {
mobileOpen.value = false
}
return {
collapsed,
mobileOpen,
toggle,
close,
toggleMobile,
closeMobile
}
}

View File

@@ -0,0 +1,52 @@
import { ref, computed, watch, onMounted } from 'vue'
type Theme = 'light' | 'dark' | 'auto'
const STORAGE_KEY = 'space-booking-theme'
const theme = ref<Theme>((localStorage.getItem(STORAGE_KEY) as Theme) || 'light')
const systemDark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', (e) => {
systemDark.value = e.matches
})
const resolvedTheme = computed<'light' | 'dark'>(() => {
if (theme.value === 'auto') {
return systemDark.value ? 'dark' : 'light'
}
return theme.value
})
function applyTheme() {
document.documentElement.setAttribute('data-theme', resolvedTheme.value)
}
watch(resolvedTheme, applyTheme)
export function useTheme() {
onMounted(() => {
applyTheme()
})
function toggleTheme() {
const cycle: Theme[] = ['light', 'dark', 'auto']
const idx = cycle.indexOf(theme.value)
theme.value = cycle[(idx + 1) % cycle.length]
localStorage.setItem(STORAGE_KEY, theme.value)
}
function setTheme(value: Theme) {
theme.value = value
localStorage.setItem(STORAGE_KEY, value)
}
return {
theme,
resolvedTheme,
toggleTheme,
setTheme
}
}

View File

@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './assets/theme.css'
import './assets/main.css' import './assets/main.css'
const app = createApp(App) const app = createApp(App)

View File

@@ -3,18 +3,19 @@
<div class="page-header"> <div class="page-header">
<h2>Admin Dashboard - Space Management</h2> <h2>Admin Dashboard - Space Management</h2>
<button class="btn btn-primary" @click="openCreateModal"> <button class="btn btn-primary" @click="openCreateModal">
<Plus :size="16" />
Create New Space Create New Space
</button> </button>
</div> </div>
<!-- Spaces List --> <!-- Spaces List -->
<div class="card"> <CollapsibleSection title="All Spaces" :icon="Building2">
<h3>All Spaces</h3>
<div v-if="loadingSpaces" class="loading">Loading spaces...</div> <div v-if="loadingSpaces" class="loading">Loading spaces...</div>
<div v-else-if="spaces.length === 0" class="empty"> <div v-else-if="spaces.length === 0" class="empty">
No spaces created yet. Create one above! No spaces created yet. Create one above!
</div> </div>
<table v-else class="spaces-table"> <div v-else class="table-responsive">
<table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
@@ -54,6 +55,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</CollapsibleSection>
<!-- Create/Edit Space Modal --> <!-- Create/Edit Space Modal -->
<div v-if="showModal" class="modal" @click.self="closeModal"> <div v-if="showModal" class="modal" @click.self="closeModal">
@@ -179,6 +181,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { spacesApi, handleApiError } from '@/services/api' import { spacesApi, handleApiError } from '@/services/api'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Building2, Plus } from 'lucide-vue-next'
import type { Space } from '@/types' import type { Space } from '@/types'
const spaces = ref<Space[]>([]) const spaces = ref<Space[]>([])
@@ -305,17 +309,18 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.admin { .page-header {
max-width: 1200px; display: flex;
margin: 0 auto; justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
} }
.card { .page-header h2 {
background: white; margin: 0;
border-radius: 8px; color: var(--color-text-primary);
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.space-form { .space-form {
@@ -328,20 +333,20 @@ onMounted(() => {
margin-top: 16px; margin-top: 16px;
margin-bottom: 8px; margin-bottom: 8px;
padding-top: 16px; padding-top: 16px;
border-top: 1px solid #e5e7eb; border-top: 1px solid var(--color-border);
} }
.form-section-header h4 { .form-section-header h4 {
margin: 0 0 4px 0; margin: 0 0 4px 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #1f2937; color: var(--color-text-primary);
} }
.help-text { .help-text {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
color: #6b7280; color: var(--color-text-secondary);
} }
.form-row { .form-row {
@@ -364,7 +369,7 @@ onMounted(() => {
.form-group label { .form-group label {
font-weight: 500; font-weight: 500;
color: #374151; color: var(--color-text-primary);
font-size: 14px; font-size: 14px;
} }
@@ -372,17 +377,19 @@ onMounted(() => {
.form-group select, .form-group select,
.form-group textarea { .form-group textarea {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #d1d5db; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
} }
.form-group input:focus, .form-group input:focus,
.form-group select:focus, .form-group select:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: #3b82f6; border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
} }
.form-actions { .form-actions {
@@ -392,13 +399,16 @@ onMounted(() => {
} }
.btn { .btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all var(--transition-fast);
} }
.btn:disabled { .btn:disabled {
@@ -407,39 +417,39 @@ onMounted(() => {
} }
.btn-primary { .btn-primary {
background: #3b82f6; background: var(--color-accent);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: #2563eb; background: var(--color-accent-hover);
} }
.btn-secondary { .btn-secondary {
background: #6b7280; background: var(--color-bg-tertiary);
color: white; color: var(--color-text-primary);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: #4b5563; background: var(--color-border);
} }
.btn-success { .btn-success {
background: #10b981; background: var(--color-success);
color: white; color: white;
} }
.btn-success:hover:not(:disabled) { .btn-success:hover:not(:disabled) {
background: #059669; background: color-mix(in srgb, var(--color-success) 85%, black);
} }
.btn-warning { .btn-warning {
background: #f59e0b; background: var(--color-warning);
color: white; color: white;
} }
.btn-warning:hover:not(:disabled) { .btn-warning:hover:not(:disabled) {
background: #d97706; background: color-mix(in srgb, var(--color-warning) 85%, black);
} }
.btn-sm { .btn-sm {
@@ -449,53 +459,58 @@ onMounted(() => {
.error { .error {
padding: 12px; padding: 12px;
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: #991b1b; color: var(--color-danger);
border-radius: 4px; border-radius: var(--radius-sm);
margin-top: 12px; margin-top: 12px;
} }
.success { .success {
padding: 12px; padding: 12px;
background: #d1fae5; background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: #065f46; color: var(--color-success);
border-radius: 4px; border-radius: var(--radius-sm);
margin-top: 12px; margin-top: 12px;
} }
.loading { .loading {
text-align: center; text-align: center;
color: #6b7280; color: var(--color-text-secondary);
padding: 24px; padding: 24px;
} }
.empty { .empty {
text-align: center; text-align: center;
color: #9ca3af; color: var(--color-text-muted);
padding: 24px; padding: 24px;
} }
.spaces-table { .table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
} }
.spaces-table th { .data-table th {
text-align: left; text-align: left;
padding: 12px; padding: 12px;
background: #f9fafb; background: var(--color-bg-secondary);
font-weight: 600; font-weight: 600;
color: #374151; color: var(--color-text-primary);
border-bottom: 2px solid #e5e7eb; border-bottom: 2px solid var(--color-border);
} }
.spaces-table td { .data-table td {
padding: 12px; padding: 12px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid var(--color-border);
color: var(--color-text-primary);
} }
.spaces-table tr:hover { .data-table tr:hover {
background: #f9fafb; background: var(--color-surface-hover);
} }
.badge { .badge {
@@ -507,13 +522,13 @@ onMounted(() => {
} }
.badge-active { .badge-active {
background: #d1fae5; background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: #065f46; color: var(--color-success);
} }
.badge-inactive { .badge-inactive {
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: #991b1b; color: var(--color-danger);
} }
.actions { .actions {
@@ -521,19 +536,6 @@ onMounted(() => {
gap: 8px; gap: 8px;
} }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.page-header h2 {
margin: 0;
}
.modal { .modal {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -548,18 +550,30 @@ onMounted(() => {
} }
.modal-content { .modal-content {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
max-width: 600px; max-width: 600px;
width: 90%; width: 90%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-lg);
} }
.modal-content h3 { .modal-content h3 {
margin-top: 0; margin-top: 0;
margin-bottom: 20px; margin-bottom: 20px;
color: var(--color-text-primary);
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.actions {
flex-direction: column;
}
} }
</style> </style>

View File

@@ -3,8 +3,7 @@
<h2>Admin Dashboard - Pending Booking Requests</h2> <h2>Admin Dashboard - Pending Booking Requests</h2>
<!-- Filters Card --> <!-- Filters Card -->
<div class="card"> <CollapsibleSection title="Filters" :icon="Filter">
<h3>Filters</h3>
<div class="filters"> <div class="filters">
<div class="form-group"> <div class="form-group">
<label for="filter-space">Filter by Space</label> <label for="filter-space">Filter by Space</label>
@@ -16,7 +15,7 @@
</select> </select>
</div> </div>
</div> </div>
</div> </CollapsibleSection>
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading" class="card"> <div v-if="loading" class="card">
@@ -32,9 +31,9 @@
</div> </div>
<!-- Bookings Table --> <!-- Bookings Table -->
<div v-else class="card"> <CollapsibleSection v-else :title="`Pending Requests (${bookings.length})`" :icon="ClipboardCheck">
<h3>Pending Requests ({{ bookings.length }})</h3> <div class="table-responsive">
<table class="bookings-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>User</th> <th>User</th>
@@ -91,6 +90,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</CollapsibleSection>
<!-- Reject Modal --> <!-- Reject Modal -->
<div v-if="rejectingBooking" class="modal" @click.self="closeRejectModal"> <div v-if="rejectingBooking" class="modal" @click.self="closeRejectModal">
@@ -134,7 +134,7 @@
<!-- Success Message --> <!-- Success Message -->
<div v-if="success" class="card"> <div v-if="success" class="card">
<div class="success">{{ success }}</div> <div class="success-msg">{{ success }}</div>
</div> </div>
</div> </div>
</template> </template>
@@ -144,6 +144,8 @@ import { ref, computed, onMounted } from 'vue'
import { adminBookingsApi, spacesApi, handleApiError } from '@/services/api' import { adminBookingsApi, spacesApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { formatDate as formatDateUtil, formatTime as formatTimeUtil } from '@/utils/datetime' import { formatDate as formatDateUtil, formatTime as formatTimeUtil } from '@/utils/datetime'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Filter, ClipboardCheck } from 'lucide-vue-next'
import type { Booking, Space } from '@/types' import type { Booking, Space } from '@/types'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -279,17 +281,24 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.admin-pending { h2 {
max-width: 1600px; margin-bottom: 24px;
margin: 0 auto; color: var(--color-text-primary);
} }
.card { .card {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
margin-bottom: 24px; margin-top: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
}
.collapsible-section + .collapsible-section,
.card + .collapsible-section,
.collapsible-section + .card {
margin-top: 16px;
} }
.filters { .filters {
@@ -306,22 +315,25 @@ onMounted(() => {
.form-group label { .form-group label {
font-weight: 500; font-weight: 500;
color: #374151; color: var(--color-text-primary);
font-size: 14px;
} }
.form-group select, .form-group select,
.form-group textarea { .form-group textarea {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #d1d5db; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
} }
.form-group select:focus, .form-group select:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: #3b82f6; border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
} }
.form-actions { .form-actions {
@@ -332,53 +344,58 @@ onMounted(() => {
.loading { .loading {
text-align: center; text-align: center;
color: #6b7280; color: var(--color-text-secondary);
padding: 24px; padding: 24px;
} }
.empty { .empty {
text-align: center; text-align: center;
color: #9ca3af; color: var(--color-text-muted);
padding: 24px; padding: 24px;
} }
.error { .error {
padding: 12px; padding: 12px;
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: #991b1b; color: var(--color-danger);
border-radius: 4px; border-radius: var(--radius-sm);
} }
.success { .success-msg {
padding: 12px; padding: 12px;
background: #d1fae5; background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: #065f46; color: var(--color-success);
border-radius: 4px; border-radius: var(--radius-sm);
} }
.bookings-table { .table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
} }
.bookings-table th { .data-table th {
text-align: left; text-align: left;
padding: 12px; padding: 12px;
background: #f9fafb; background: var(--color-bg-secondary);
font-weight: 600; font-weight: 600;
color: #374151; color: var(--color-text-primary);
border-bottom: 2px solid #e5e7eb; border-bottom: 2px solid var(--color-border);
white-space: nowrap; white-space: nowrap;
} }
.bookings-table td { .data-table td {
padding: 12px; padding: 12px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid var(--color-border);
vertical-align: top; vertical-align: top;
color: var(--color-text-primary);
} }
.bookings-table tr:hover { .data-table tr:hover {
background: #f9fafb; background: var(--color-surface-hover);
} }
.user-info, .user-info,
@@ -391,14 +408,14 @@ onMounted(() => {
.user-name, .user-name,
.space-name { .space-name {
font-weight: 500; font-weight: 500;
color: #374151; color: var(--color-text-primary);
} }
.user-email, .user-email,
.user-org, .user-org,
.space-type { .space-type {
font-size: 12px; font-size: 12px;
color: #6b7280; color: var(--color-text-secondary);
} }
.description { .description {
@@ -416,11 +433,11 @@ onMounted(() => {
.btn { .btn {
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all var(--transition-fast);
} }
.btn:disabled { .btn:disabled {
@@ -434,30 +451,30 @@ onMounted(() => {
} }
.btn-success { .btn-success {
background: #10b981; background: var(--color-success);
color: white; color: white;
} }
.btn-success:hover:not(:disabled) { .btn-success:hover:not(:disabled) {
background: #059669; background: color-mix(in srgb, var(--color-success) 85%, black);
} }
.btn-danger { .btn-danger {
background: #ef4444; background: var(--color-danger);
color: white; color: white;
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
background: #dc2626; background: color-mix(in srgb, var(--color-danger) 85%, black);
} }
.btn-secondary { .btn-secondary {
background: #6b7280; background: var(--color-bg-tertiary);
color: white; color: var(--color-text-primary);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: #4b5563; background: var(--color-border);
} }
.modal { .modal {
@@ -474,22 +491,23 @@ onMounted(() => {
} }
.modal-content { .modal-content {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
max-width: 600px; max-width: 600px;
width: 90%; width: 90%;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-lg);
} }
.modal-content h3 { .modal-content h3 {
margin-top: 0; margin-top: 0;
margin-bottom: 16px; margin-bottom: 16px;
color: var(--color-text-primary);
} }
.booking-summary { .booking-summary {
background: #f9fafb; background: var(--color-bg-secondary);
border-radius: 4px; border-radius: var(--radius-sm);
padding: 12px; padding: 12px;
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -497,10 +515,10 @@ onMounted(() => {
.booking-summary p { .booking-summary p {
margin: 8px 0; margin: 8px 0;
font-size: 14px; font-size: 14px;
color: #374151; color: var(--color-text-secondary);
} }
.booking-summary strong { .booking-summary strong {
color: #1f2937; color: var(--color-text-primary);
} }
</style> </style>

View File

@@ -3,6 +3,7 @@
<h2>Booking Reports</h2> <h2>Booking Reports</h2>
<!-- Date Range Filter --> <!-- Date Range Filter -->
<CollapsibleSection title="Date Range Filter" :icon="CalendarDays">
<div class="filters"> <div class="filters">
<label> <label>
Start Date: Start Date:
@@ -14,9 +15,10 @@
<input type="date" v-model="endDate" /> <input type="date" v-model="endDate" />
</label> </label>
<button @click="loadReports" class="btn-primary">Refresh</button> <button @click="loadReports" class="btn btn-primary">Refresh</button>
<button @click="clearFilters" class="btn-secondary">Clear Filters</button> <button @click="clearFilters" class="btn btn-secondary">Clear Filters</button>
</div> </div>
</CollapsibleSection>
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading" class="loading">Loading reports...</div> <div v-if="loading" class="loading">Loading reports...</div>
@@ -53,6 +55,7 @@
<div v-if="activeTab === 'usage' && !loading" class="report-content"> <div v-if="activeTab === 'usage' && !loading" class="report-content">
<h3>Space Usage Report</h3> <h3>Space Usage Report</h3>
<canvas ref="usageChart"></canvas> <canvas ref="usageChart"></canvas>
<div class="table-responsive">
<table class="report-table"> <table class="report-table">
<thead> <thead>
<tr> <tr>
@@ -85,11 +88,13 @@
</tfoot> </tfoot>
</table> </table>
</div> </div>
</div>
<!-- Top Users Report --> <!-- Top Users Report -->
<div v-if="activeTab === 'users' && !loading" class="report-content"> <div v-if="activeTab === 'users' && !loading" class="report-content">
<h3>Top Users Report</h3> <h3>Top Users Report</h3>
<canvas ref="usersChart"></canvas> <canvas ref="usersChart"></canvas>
<div class="table-responsive">
<table class="report-table"> <table class="report-table">
<thead> <thead>
<tr> <tr>
@@ -111,6 +116,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- Approval Rate Report --> <!-- Approval Rate Report -->
<div v-if="activeTab === 'approval' && !loading" class="report-content"> <div v-if="activeTab === 'approval' && !loading" class="report-content">
@@ -144,6 +150,8 @@
import { ref, onMounted, watch, nextTick } from 'vue' import { ref, onMounted, watch, nextTick } from 'vue'
import { reportsApi } from '@/services/api' import { reportsApi } from '@/services/api'
import Chart from 'chart.js/auto' import Chart from 'chart.js/auto'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { CalendarDays } from 'lucide-vue-next'
import type { SpaceUsageReport, TopUsersReport, ApprovalRateReport } from '@/types' import type { SpaceUsageReport, TopUsersReport, ApprovalRateReport } from '@/types'
const activeTab = ref('usage') const activeTab = ref('usage')
@@ -295,25 +303,16 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.admin-reports {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
h2 { h2 {
margin-bottom: 20px; margin-bottom: 20px;
color: #333; color: var(--color-text-primary);
} }
.filters { .filters {
display: flex; display: flex;
gap: 15px; gap: 15px;
margin-bottom: 30px;
align-items: center; align-items: center;
padding: 15px; flex-wrap: wrap;
background: #f5f5f5;
border-radius: 8px;
} }
.filters label { .filters label {
@@ -321,58 +320,67 @@ h2 {
flex-direction: column; flex-direction: column;
gap: 5px; gap: 5px;
font-weight: 500; font-weight: 500;
color: var(--color-text-primary);
font-size: 14px;
} }
.filters input[type='date'] { .filters input[type='date'] {
padding: 8px; padding: 8px;
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-primary);
} }
.btn-primary, .btn {
.btn-secondary {
padding: 8px 16px; padding: 8px 16px;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
font-size: 14px;
transition: all var(--transition-fast);
} }
.btn-primary { .btn-primary {
background: #4caf50; background: var(--color-accent);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background: #45a049; background: var(--color-accent-hover);
} }
.btn-secondary { .btn-secondary {
background: #9e9e9e; background: var(--color-bg-tertiary);
color: white; color: var(--color-text-primary);
} }
.btn-secondary:hover { .btn-secondary:hover {
background: #757575; background: var(--color-border);
} }
.loading, .loading,
.error { .error {
padding: 20px; padding: 20px;
text-align: center; text-align: center;
border-radius: 4px; border-radius: var(--radius-sm);
}
.loading {
color: var(--color-text-secondary);
} }
.error { .error {
background: #ffebee; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: #c62828; color: var(--color-danger);
} }
.tabs { .tabs {
display: flex; display: flex;
gap: 10px; gap: 10px;
margin-bottom: 20px; margin: 20px 0;
border-bottom: 2px solid #ddd; border-bottom: 2px solid var(--color-border);
} }
.tab-button { .tab-button {
@@ -382,29 +390,30 @@ h2 {
border-bottom: 3px solid transparent; border-bottom: 3px solid transparent;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
color: #666; color: var(--color-text-secondary);
transition: all 0.3s; transition: all var(--transition-fast);
} }
.tab-button:hover { .tab-button:hover {
color: #333; color: var(--color-text-primary);
} }
.tab-button.active { .tab-button.active {
color: #4caf50; color: var(--color-accent);
border-bottom-color: #4caf50; border-bottom-color: var(--color-accent);
} }
.report-content { .report-content {
background: white; background: var(--color-surface);
padding: 20px; padding: 20px;
border-radius: 8px; border-radius: var(--radius-md);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
} }
.report-content h3 { .report-content h3 {
margin-bottom: 20px; margin-bottom: 20px;
color: #333; color: var(--color-text-primary);
} }
canvas { canvas {
@@ -412,6 +421,10 @@ canvas {
margin-bottom: 30px; margin-bottom: 30px;
} }
.table-responsive {
overflow-x: auto;
}
.report-table { .report-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -422,38 +435,38 @@ canvas {
.report-table td { .report-table td {
padding: 12px; padding: 12px;
text-align: left; text-align: left;
border-bottom: 1px solid #ddd; border-bottom: 1px solid var(--color-border);
color: var(--color-text-primary);
} }
.report-table th { .report-table th {
background: #f5f5f5; background: var(--color-bg-secondary);
font-weight: 600; font-weight: 600;
color: #333;
} }
.report-table tbody tr:hover { .report-table tbody tr:hover {
background: #f9f9f9; background: var(--color-surface-hover);
} }
.report-table tfoot { .report-table tfoot {
font-weight: bold; font-weight: bold;
background: #f5f5f5; background: var(--color-bg-secondary);
} }
.status-approved { .status-approved {
color: #4caf50; color: var(--color-success);
} }
.status-pending { .status-pending {
color: #ffa500; color: var(--color-warning);
} }
.status-rejected { .status-rejected {
color: #f44336; color: var(--color-danger);
} }
.status-canceled { .status-canceled {
color: #9e9e9e; color: var(--color-text-muted);
} }
.stats { .stats {
@@ -465,8 +478,8 @@ canvas {
.stat-card { .stat-card {
padding: 20px; padding: 20px;
background: #f5f5f5; background: var(--color-bg-secondary);
border-radius: 8px; border-radius: var(--radius-md);
text-align: center; text-align: center;
border: 2px solid transparent; border: 2px solid transparent;
} }
@@ -474,42 +487,54 @@ canvas {
.stat-card h3 { .stat-card h3 {
font-size: 2em; font-size: 2em;
margin: 0 0 10px 0; margin: 0 0 10px 0;
color: #333; color: var(--color-text-primary);
} }
.stat-card p { .stat-card p {
margin: 0; margin: 0;
color: #666; color: var(--color-text-secondary);
font-weight: 500; font-weight: 500;
} }
.stat-card.approved { .stat-card.approved {
background: #e8f5e9; background: color-mix(in srgb, var(--color-success) 10%, transparent);
border-color: #4caf50; border-color: var(--color-success);
} }
.stat-card.approved h3 { .stat-card.approved h3 {
color: #4caf50; color: var(--color-success);
} }
.stat-card.rejected { .stat-card.rejected {
background: #ffebee; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-color: #f44336; border-color: var(--color-danger);
} }
.stat-card.rejected h3 { .stat-card.rejected h3 {
color: #f44336; color: var(--color-danger);
} }
.breakdown { .breakdown {
margin-top: 20px; margin-top: 20px;
padding: 15px; padding: 15px;
background: #f5f5f5; background: var(--color-bg-secondary);
border-radius: 8px; border-radius: var(--radius-md);
} }
.breakdown p { .breakdown p {
margin: 8px 0; margin: 8px 0;
font-size: 1.1em; font-size: 1.1em;
color: var(--color-text-primary);
}
.breakdown strong {
color: var(--color-text-primary);
}
@media (max-width: 768px) {
.filters {
flex-direction: column;
align-items: stretch;
}
} }
</style> </style>

View File

@@ -19,18 +19,19 @@
<input type="date" v-model="filters.start_date" placeholder="Data început" /> <input type="date" v-model="filters.start_date" placeholder="Data început" />
<input type="date" v-model="filters.end_date" placeholder="Data sfârșit" /> <input type="date" v-model="filters.end_date" placeholder="Data sfârșit" />
<button @click="loadLogs">Filtrează</button> <button @click="loadLogs" class="btn btn-primary">Filtrează</button>
<button @click="resetFilters">Resetează</button> <button @click="resetFilters" class="btn btn-secondary">Resetează</button>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
<p v-if="loading">Se încarcă...</p> <p v-if="loading" class="loading-text">Se încarcă...</p>
<!-- Error state --> <!-- Error state -->
<p v-else-if="error" class="error">{{ error }}</p> <p v-else-if="error" class="error">{{ error }}</p>
<!-- Table --> <!-- Table -->
<table v-else-if="logs.length > 0"> <div v-else-if="logs.length > 0" class="table-responsive">
<table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Timestamp</th> <th>Timestamp</th>
@@ -60,14 +61,15 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
<p v-else>Nu există înregistrări în jurnal.</p> <p v-else class="empty-text">Nu există înregistrări în jurnal.</p>
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" v-if="logs.length > 0"> <div class="pagination" v-if="logs.length > 0">
<button @click="prevPage" :disabled="page === 1">Anterior</button> <button @click="prevPage" :disabled="page === 1" class="btn btn-secondary">Anterior</button>
<span>Pagina {{ page }}</span> <span>Pagina {{ page }}</span>
<button @click="nextPage" :disabled="logs.length < limit">Următor</button> <button @click="nextPage" :disabled="logs.length < limit" class="btn btn-secondary">Următor</button>
</div> </div>
</div> </div>
</template> </template>
@@ -158,14 +160,9 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.audit-log {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
h2 { h2 {
margin-bottom: 20px; margin-bottom: 20px;
color: var(--color-text-primary);
} }
.filters { .filters {
@@ -179,81 +176,114 @@ h2 {
.filters select, .filters select,
.filters input { .filters input {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
} }
.filters button { .btn {
padding: 8px 16px; padding: 8px 16px;
background-color: #4caf50;
color: white;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
font-weight: 500;
transition: all var(--transition-fast);
} }
.filters button:hover { .btn:disabled {
background-color: #45a049; opacity: 0.5;
cursor: not-allowed;
} }
.filters button:last-child { .btn-primary {
background-color: #9e9e9e; background: var(--color-accent);
color: white;
} }
.filters button:last-child:hover { .btn-primary:hover:not(:disabled) {
background-color: #757575; background: var(--color-accent-hover);
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
}
.loading-text {
color: var(--color-text-secondary);
text-align: center;
padding: 20px;
}
.empty-text {
color: var(--color-text-muted);
text-align: center;
padding: 20px;
} }
.error { .error {
color: #f44336; color: var(--color-danger);
padding: 10px; padding: 10px;
background-color: #ffebee; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-radius: 4px; border-radius: var(--radius-sm);
margin-bottom: 20px; margin-bottom: 20px;
} }
table { .table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background-color: white; background: var(--color-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
border-radius: var(--radius-md);
} }
thead { .data-table thead {
background-color: #f5f5f5; background: var(--color-bg-secondary);
} }
th { .data-table th {
padding: 12px; padding: 12px;
text-align: left; text-align: left;
font-weight: 600; font-weight: 600;
border-bottom: 2px solid #e0e0e0; color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
} }
td { .data-table td {
padding: 12px; padding: 12px;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--color-border);
color: var(--color-text-primary);
} }
td small { .data-table td small {
color: #757575; color: var(--color-text-secondary);
font-size: 12px; font-size: 12px;
} }
tbody tr:hover { .data-table tbody tr:hover {
background-color: #f9f9f9; background: var(--color-surface-hover);
} }
pre { pre {
margin: 0; margin: 0;
padding: 8px; padding: 8px;
background-color: #f5f5f5; background: var(--color-bg-secondary);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 12px; font-size: 12px;
overflow-x: auto; overflow-x: auto;
max-width: 300px; max-width: 300px;
color: var(--color-text-primary);
} }
.pagination { .pagination {
@@ -264,25 +294,20 @@ pre {
margin-top: 20px; margin-top: 20px;
} }
.pagination button {
padding: 8px 16px;
background-color: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background-color: #1976d2;
}
.pagination button:disabled {
background-color: #e0e0e0;
cursor: not-allowed;
}
.pagination span { .pagination span {
font-weight: 500; font-weight: 500;
color: var(--color-text-primary);
}
@media (max-width: 768px) {
.filters {
flex-direction: column;
}
.filters select,
.filters input,
.btn {
width: 100%;
}
} }
</style> </style>

View File

@@ -11,17 +11,11 @@
<!-- Dashboard Content --> <!-- Dashboard Content -->
<div v-else class="dashboard-content"> <div v-else class="dashboard-content">
<!-- Quick Stats --> <!-- Quick Stats -->
<CollapsibleSection title="Quick Stats" :icon="BarChart3">
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon stat-icon-total"> <div class="stat-icon stat-icon-total">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Calendar :size="28" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<h3>{{ stats.total }}</h3> <h3>{{ stats.total }}</h3>
@@ -31,14 +25,7 @@
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon stat-icon-pending"> <div class="stat-icon stat-icon-pending">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Clock :size="28" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<h3>{{ stats.pending }}</h3> <h3>{{ stats.pending }}</h3>
@@ -48,14 +35,7 @@
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon stat-icon-approved"> <div class="stat-icon stat-icon-approved">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <CheckCircle :size="28" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<h3>{{ stats.approved }}</h3> <h3>{{ stats.approved }}</h3>
@@ -66,14 +46,7 @@
<!-- Admin: Pending Requests --> <!-- Admin: Pending Requests -->
<div v-if="isAdmin" class="stat-card"> <div v-if="isAdmin" class="stat-card">
<div class="stat-icon stat-icon-admin"> <div class="stat-icon stat-icon-admin">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Users :size="28" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<h3>{{ adminStats.pendingRequests }}</h3> <h3>{{ adminStats.pendingRequests }}</h3>
@@ -81,56 +54,29 @@
</div> </div>
</div> </div>
</div> </div>
</CollapsibleSection>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="card quick-actions"> <div class="card quick-actions">
<h3>Quick Actions</h3> <h3>Quick Actions</h3>
<div class="actions-grid"> <div class="actions-grid">
<router-link to="/spaces" class="action-btn"> <router-link to="/spaces" class="action-btn">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Search :size="24" class="action-icon" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<span>Book a Space</span> <span>Book a Space</span>
</router-link> </router-link>
<router-link to="/my-bookings" class="action-btn"> <router-link to="/my-bookings" class="action-btn">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <ClipboardList :size="24" class="action-icon" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<span>My Bookings</span> <span>My Bookings</span>
</router-link> </router-link>
<router-link v-if="isAdmin" to="/admin/bookings" class="action-btn"> <router-link v-if="isAdmin" to="/admin/bookings" class="action-btn">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <ClipboardCheck :size="24" class="action-icon" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<span>Manage Bookings</span> <span>Manage Bookings</span>
</router-link> </router-link>
<router-link v-if="isAdmin" to="/admin/spaces" class="action-btn"> <router-link v-if="isAdmin" to="/admin/spaces" class="action-btn">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Building2 :size="24" class="action-icon" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<span>Manage Spaces</span> <span>Manage Spaces</span>
</router-link> </router-link>
</div> </div>
@@ -138,21 +84,13 @@
<div class="content-grid"> <div class="content-grid">
<!-- Upcoming Bookings --> <!-- Upcoming Bookings -->
<div class="card"> <CollapsibleSection title="Upcoming Bookings" :icon="CalendarDays">
<div class="card-header"> <template #header-actions>
<h3>Upcoming Bookings</h3>
<router-link to="/my-bookings" class="link">View All</router-link> <router-link to="/my-bookings" class="link">View All</router-link>
</div> </template>
<div v-if="upcomingBookings.length === 0" class="empty-state-small"> <div v-if="upcomingBookings.length === 0" class="empty-state-small">
<svg class="icon-empty" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Calendar :size="48" class="icon-empty" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p>No upcoming bookings</p> <p>No upcoming bookings</p>
</div> </div>
@@ -163,14 +101,7 @@
class="booking-item" class="booking-item"
> >
<div class="booking-icon"> <div class="booking-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Calendar :size="20" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div> </div>
<div class="booking-info"> <div class="booking-info">
<h4>{{ booking.title }}</h4> <h4>{{ booking.title }}</h4>
@@ -184,24 +115,16 @@
</span> </span>
</div> </div>
</div> </div>
</div> </CollapsibleSection>
<!-- Available Spaces --> <!-- Available Spaces -->
<div class="card"> <CollapsibleSection title="Available Spaces" :icon="Building2">
<div class="card-header"> <template #header-actions>
<h3>Available Spaces</h3>
<router-link to="/spaces" class="link">View All</router-link> <router-link to="/spaces" class="link">View All</router-link>
</div> </template>
<div v-if="availableSpaces.length === 0" class="empty-state-small"> <div v-if="availableSpaces.length === 0" class="empty-state-small">
<svg class="icon-empty" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Building2 :size="48" class="icon-empty" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<p>No available spaces</p> <p>No available spaces</p>
</div> </div>
@@ -213,14 +136,7 @@
class="space-item" class="space-item"
> >
<div class="space-icon"> <div class="space-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Building2 :size="20" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
</div> </div>
<div class="space-info"> <div class="space-info">
<h4>{{ space.name }}</h4> <h4>{{ space.name }}</h4>
@@ -228,25 +144,17 @@
{{ formatType(space.type) }} · Capacity: {{ space.capacity }} {{ formatType(space.type) }} · Capacity: {{ space.capacity }}
</p> </p>
</div> </div>
<svg class="icon-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <ChevronRight :size="20" class="icon-chevron" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</router-link> </router-link>
</div> </div>
</div> </CollapsibleSection>
</div> </div>
<!-- Admin: Recent Audit Logs --> <!-- Admin: Recent Audit Logs -->
<div v-if="isAdmin" class="card"> <CollapsibleSection v-if="isAdmin" title="Recent Activity" :icon="ScrollText">
<div class="card-header"> <template #header-actions>
<h3>Recent Activity</h3>
<router-link to="/admin/audit-log" class="link">View All</router-link> <router-link to="/admin/audit-log" class="link">View All</router-link>
</div> </template>
<div v-if="recentAuditLogs.length === 0" class="empty-state-small"> <div v-if="recentAuditLogs.length === 0" class="empty-state-small">
<p>No recent activity</p> <p>No recent activity</p>
@@ -255,14 +163,7 @@
<div v-else class="audit-list"> <div v-else class="audit-list">
<div v-for="log in recentAuditLogs" :key="log.id" class="audit-item"> <div v-for="log in recentAuditLogs" :key="log.id" class="audit-item">
<div class="audit-icon"> <div class="audit-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <FileText :size="20" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div> </div>
<div class="audit-info"> <div class="audit-info">
<p class="audit-action">{{ log.action }}</p> <p class="audit-action">{{ log.action }}</p>
@@ -271,7 +172,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </CollapsibleSection>
</div> </div>
</div> </div>
</template> </template>
@@ -288,6 +189,22 @@ import {
} from '@/services/api' } from '@/services/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil, ensureUTC } from '@/utils/datetime' import { formatDateTime as formatDateTimeUtil, ensureUTC } from '@/utils/datetime'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import {
Calendar,
CalendarDays,
Clock,
CheckCircle,
Users,
Search,
ClipboardList,
ClipboardCheck,
Building2,
ChevronRight,
FileText,
ScrollText,
BarChart3
} from 'lucide-vue-next'
import type { Space, Booking, AuditLog, User } from '@/types' import type { Space, Booking, AuditLog, User } from '@/types'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -402,15 +319,10 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.dashboard {
max-width: 1400px;
margin: 0 auto;
}
.dashboard h2 { .dashboard h2 {
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
color: #111827; color: var(--color-text-primary);
margin-bottom: 32px; margin-bottom: 32px;
} }
@@ -421,14 +333,14 @@ onMounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 80px 20px; padding: 80px 20px;
color: #6b7280; color: var(--color-text-secondary);
} }
.spinner { .spinner {
width: 48px; width: 48px;
height: 48px; height: 48px;
border: 4px solid #e5e7eb; border: 4px solid var(--color-border);
border-top-color: #3b82f6; border-top-color: var(--color-accent);
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
margin-bottom: 16px; margin-bottom: 16px;
@@ -444,21 +356,21 @@ onMounted(() => {
.dashboard-content { .dashboard-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 32px; gap: 24px;
} }
/* Stats Grid */ /* Stats Grid */
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px; gap: 16px;
} }
.stat-card { .stat-card {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); border: 1px solid var(--color-border-light);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
@@ -467,53 +379,49 @@ onMounted(() => {
.stat-icon { .stat-icon {
width: 56px; width: 56px;
height: 56px; height: 56px;
border-radius: 12px; border-radius: var(--radius-lg);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.stat-icon svg {
width: 28px;
height: 28px;
}
.stat-icon-total { .stat-icon-total {
background: #dbeafe; background: var(--color-accent-light);
color: #1e40af; color: var(--color-info);
} }
.stat-icon-pending { .stat-icon-pending {
background: #fef3c7; background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: #92400e; color: var(--color-warning);
} }
.stat-icon-approved { .stat-icon-approved {
background: #d1fae5; background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: #065f46; color: var(--color-success);
} }
.stat-icon-admin { .stat-icon-admin {
background: #e0e7ff; background: var(--color-accent-light);
color: #3730a3; color: var(--color-accent);
} }
.stat-content h3 { .stat-content h3 {
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
color: #111827; color: var(--color-text-primary);
margin-bottom: 4px; margin-bottom: 4px;
} }
.stat-content p { .stat-content p {
color: #6b7280; color: var(--color-text-secondary);
font-size: 14px; font-size: 14px;
} }
/* Quick Actions */ /* Quick Actions */
.quick-actions h3 { .quick-actions h3 {
margin-bottom: 20px; margin-bottom: 20px;
color: var(--color-text-primary);
} }
.actions-grid { .actions-grid {
@@ -527,23 +435,21 @@ onMounted(() => {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 16px 20px; padding: 16px 20px;
background: #f3f4f6; background: var(--color-bg-secondary);
border-radius: 8px; border-radius: var(--radius-md);
color: #374151; color: var(--color-text-primary);
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
transition: all 0.2s; transition: all var(--transition-fast);
} }
.action-btn:hover { .action-btn:hover {
background: #e5e7eb; background: var(--color-bg-tertiary);
transform: translateY(-2px); transform: translateY(-2px);
} }
.action-btn .icon { .action-icon {
width: 24px; color: var(--color-accent);
height: 24px;
color: #3b82f6;
} }
/* Content Grid */ /* Content Grid */
@@ -555,35 +461,23 @@ onMounted(() => {
/* Cards */ /* Cards */
.card { .card {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
} border: 1px solid var(--color-border);
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.card h3 {
font-size: 20px;
font-weight: 600;
color: #111827;
} }
.link { .link {
color: #3b82f6; color: var(--color-accent);
text-decoration: none; text-decoration: none;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
transition: color 0.2s; transition: color var(--transition-fast);
} }
.link:hover { .link:hover {
color: #2563eb; color: var(--color-accent-hover);
text-decoration: underline; text-decoration: underline;
} }
@@ -591,18 +485,17 @@ onMounted(() => {
.empty-state-small { .empty-state-small {
text-align: center; text-align: center;
padding: 40px 20px; padding: 40px 20px;
color: #9ca3af; color: var(--color-text-muted);
} }
.icon-empty { .icon-empty {
width: 48px;
height: 48px;
margin: 0 auto 12px; margin: 0 auto 12px;
color: #d1d5db; color: var(--color-border);
display: block;
} }
.empty-state-small p { .empty-state-small p {
color: #6b7280; color: var(--color-text-secondary);
font-size: 14px; font-size: 14px;
} }
@@ -610,38 +503,33 @@ onMounted(() => {
.bookings-list { .bookings-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 12px;
} }
.booking-item { .booking-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
padding: 16px; padding: 14px;
background: #f9fafb; background: var(--color-bg-secondary);
border-radius: 8px; border-radius: var(--radius-md);
transition: background 0.2s; transition: background var(--transition-fast);
} }
.booking-item:hover { .booking-item:hover {
background: #f3f4f6; background: var(--color-bg-tertiary);
} }
.booking-icon { .booking-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} color: var(--color-accent);
.booking-icon svg {
width: 20px;
height: 20px;
color: #3b82f6;
} }
.booking-info { .booking-info {
@@ -651,86 +539,53 @@ onMounted(() => {
.booking-info h4 { .booking-info h4 {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #111827; color: var(--color-text-primary);
margin-bottom: 4px; margin-bottom: 4px;
} }
.booking-space { .booking-space {
font-size: 14px; font-size: 14px;
color: #6b7280; color: var(--color-text-secondary);
margin-bottom: 2px; margin-bottom: 2px;
} }
.booking-time { .booking-time {
font-size: 13px; font-size: 13px;
color: #9ca3af; color: var(--color-text-muted);
}
.badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.badge-pending {
background: #fef3c7;
color: #92400e;
}
.badge-approved {
background: #d1fae5;
color: #065f46;
}
.badge-rejected {
background: #fee2e2;
color: #991b1b;
}
.badge-canceled {
background: #e5e7eb;
color: #4b5563;
} }
/* Spaces List */ /* Spaces List */
.spaces-list { .spaces-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 8px;
} }
.space-item { .space-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 16px; padding: 14px;
background: #f9fafb; background: var(--color-bg-secondary);
border-radius: 8px; border-radius: var(--radius-md);
text-decoration: none; text-decoration: none;
transition: background 0.2s; transition: background var(--transition-fast);
} }
.space-item:hover { .space-item:hover {
background: #f3f4f6; background: var(--color-bg-tertiary);
} }
.space-icon { .space-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} color: var(--color-accent);
.space-icon svg {
width: 20px;
height: 20px;
color: #3b82f6;
} }
.space-info { .space-info {
@@ -740,51 +595,44 @@ onMounted(() => {
.space-info h4 { .space-info h4 {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #111827; color: var(--color-text-primary);
margin-bottom: 2px; margin-bottom: 2px;
} }
.space-meta { .space-meta {
font-size: 13px; font-size: 13px;
color: #6b7280; color: var(--color-text-secondary);
} }
.icon-chevron { .icon-chevron {
width: 20px; color: var(--color-text-muted);
height: 20px;
color: #9ca3af;
} }
/* Audit List */ /* Audit List */
.audit-list { .audit-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 12px;
} }
.audit-item { .audit-item {
display: flex; display: flex;
gap: 12px; gap: 12px;
padding: 16px; padding: 14px;
background: #f9fafb; background: var(--color-bg-secondary);
border-radius: 8px; border-radius: var(--radius-md);
} }
.audit-icon { .audit-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} color: var(--color-text-secondary);
.audit-icon svg {
width: 20px;
height: 20px;
color: #6b7280;
} }
.audit-info { .audit-info {
@@ -794,19 +642,19 @@ onMounted(() => {
.audit-action { .audit-action {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #111827; color: var(--color-text-primary);
margin-bottom: 4px; margin-bottom: 4px;
} }
.audit-user { .audit-user {
font-size: 13px; font-size: 13px;
color: #6b7280; color: var(--color-text-secondary);
margin-bottom: 2px; margin-bottom: 2px;
} }
.audit-time { .audit-time {
font-size: 12px; font-size: 12px;
color: #9ca3af; color: var(--color-text-muted);
} }
/* Responsive */ /* Responsive */

View File

@@ -100,12 +100,12 @@ const handleLogin = async () => {
h2 { h2 {
text-align: center; text-align: center;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #2c3e50; color: var(--color-text-primary);
} }
.subtitle { .subtitle {
text-align: center; text-align: center;
color: #7f8c8d; color: var(--color-text-secondary);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -117,11 +117,11 @@ h2 {
.register-link { .register-link {
text-align: center; text-align: center;
margin-top: 1.5rem; margin-top: 1.5rem;
color: #7f8c8d; color: var(--color-text-secondary);
} }
.register-link a { .register-link a {
color: #3498db; color: var(--color-accent);
text-decoration: none; text-decoration: none;
} }
@@ -132,15 +132,15 @@ h2 {
.demo-accounts { .demo-accounts {
margin-top: 2rem; margin-top: 2rem;
padding-top: 1.5rem; padding-top: 1.5rem;
border-top: 1px solid #e1e8ed; border-top: 1px solid var(--color-border);
font-size: 0.9rem; font-size: 0.9rem;
color: #7f8c8d; color: var(--color-text-secondary);
} }
.demo-title { .demo-title {
font-weight: 600; font-weight: 600;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #2c3e50; color: var(--color-text-primary);
} }
.demo-accounts p { .demo-accounts p {
@@ -150,8 +150,9 @@ h2 {
.error { .error {
margin-top: 1rem; margin-top: 1rem;
padding: 0.75rem; padding: 0.75rem;
background: #fee; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-left: 3px solid #e74c3c; border-left: 3px solid var(--color-danger);
border-radius: 4px; border-radius: var(--radius-sm);
color: var(--color-danger);
} }
</style> </style>

View File

@@ -297,11 +297,9 @@ const formatStatus = (status: string): string => {
const openEditModal = (booking: Booking) => { const openEditModal = (booking: Booking) => {
editingBooking.value = booking editingBooking.value = booking
// Extract date and time from ISO datetime
const startLocal = isoToLocalDateTime(booking.start_datetime, userTimezone.value) const startLocal = isoToLocalDateTime(booking.start_datetime, userTimezone.value)
const endLocal = isoToLocalDateTime(booking.end_datetime, userTimezone.value) const endLocal = isoToLocalDateTime(booking.end_datetime, userTimezone.value)
// Split YYYY-MM-DDTHH:mm into date and time parts
const [startDate, startTime] = startLocal.split('T') const [startDate, startTime] = startLocal.split('T')
const [endDate, endTime] = endLocal.split('T') const [endDate, endTime] = endLocal.split('T')
@@ -330,7 +328,6 @@ const saveEdit = async () => {
editError.value = '' editError.value = ''
try { try {
// Combine date and time, then convert to ISO
const startDateTime = `${editForm.value.start_date}T${editForm.value.start_time}` const startDateTime = `${editForm.value.start_date}T${editForm.value.start_time}`
const endDateTime = `${editForm.value.end_date}T${editForm.value.end_time}` const endDateTime = `${editForm.value.end_date}T${editForm.value.end_time}`
@@ -352,7 +349,6 @@ const saveEdit = async () => {
} }
const canCancel = (booking: Booking): boolean => { const canCancel = (booking: Booking): boolean => {
// Can only cancel pending or approved bookings
return booking.status === 'pending' || booking.status === 'approved' return booking.status === 'pending' || booking.status === 'approved'
} }
@@ -365,9 +361,6 @@ const handleCancel = async (booking: Booking) => {
error.value = '' error.value = ''
try { try {
// TODO: Implement cancel endpoint when available
// await bookingsApi.cancel(booking.id)
// For now, just show a message
alert('Cancel functionality will be implemented in a future update.') alert('Cancel functionality will be implemented in a future update.')
await loadBookings() await loadBookings()
} catch (err) { } catch (err) {
@@ -383,17 +376,18 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.my-bookings { h2 {
max-width: 1400px; margin-bottom: 24px;
margin: 0 auto; color: var(--color-text-primary);
} }
.card { .card {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
margin-bottom: 24px; margin-bottom: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
} }
.filter-card { .filter-card {
@@ -408,34 +402,35 @@ onMounted(() => {
.filter-group label { .filter-group label {
font-weight: 500; font-weight: 500;
color: #374151; color: var(--color-text-primary);
} }
.filter-group select { .filter-group select {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #d1d5db; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
background: white; background: var(--color-surface);
color: var(--color-text-primary);
min-width: 150px; min-width: 150px;
} }
.filter-group select:focus { .filter-group select:focus {
outline: none; outline: none;
border-color: #3b82f6; border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
} }
.loading { .loading {
text-align: center; text-align: center;
color: #6b7280; color: var(--color-text-secondary);
padding: 24px; padding: 24px;
} }
.empty { .empty {
text-align: center; text-align: center;
color: #9ca3af; color: var(--color-text-muted);
padding: 24px; padding: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -445,9 +440,9 @@ onMounted(() => {
.error { .error {
padding: 12px; padding: 12px;
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: #991b1b; color: var(--color-danger);
border-radius: 4px; border-radius: var(--radius-sm);
} }
.bookings-table { .bookings-table {
@@ -459,20 +454,21 @@ onMounted(() => {
.bookings-table th { .bookings-table th {
text-align: left; text-align: left;
padding: 12px; padding: 12px;
background: #f9fafb; background: var(--color-bg-secondary);
font-weight: 600; font-weight: 600;
color: #374151; color: var(--color-text-primary);
border-bottom: 2px solid #e5e7eb; border-bottom: 2px solid var(--color-border);
} }
.bookings-table td { .bookings-table td {
padding: 12px; padding: 12px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid var(--color-border);
vertical-align: middle; vertical-align: middle;
color: var(--color-text-primary);
} }
.bookings-table tr:hover { .bookings-table tr:hover {
background: #f9fafb; background: var(--color-surface-hover);
} }
.space-info { .space-info {
@@ -483,12 +479,12 @@ onMounted(() => {
.space-name { .space-name {
font-weight: 500; font-weight: 500;
color: #374151; color: var(--color-text-primary);
} }
.space-type { .space-type {
font-size: 12px; font-size: 12px;
color: #6b7280; color: var(--color-text-secondary);
} }
.badge { .badge {
@@ -500,23 +496,23 @@ onMounted(() => {
} }
.badge-pending { .badge-pending {
background: #fef3c7; background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: #92400e; color: var(--color-warning);
} }
.badge-approved { .badge-approved {
background: #d1fae5; background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: #065f46; color: var(--color-success);
} }
.badge-rejected { .badge-rejected {
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: #991b1b; color: var(--color-danger);
} }
.badge-canceled { .badge-canceled {
background: #f3f4f6; background: var(--color-bg-tertiary);
color: #6b7280; color: var(--color-text-secondary);
} }
.actions { .actions {
@@ -525,17 +521,17 @@ onMounted(() => {
} }
.no-action { .no-action {
color: #9ca3af; color: var(--color-text-muted);
} }
.btn { .btn {
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all var(--transition-fast);
text-decoration: none; text-decoration: none;
display: inline-block; display: inline-block;
} }
@@ -546,21 +542,30 @@ onMounted(() => {
} }
.btn-primary { .btn-primary {
background: #3b82f6; background: var(--color-accent);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: #2563eb; background: var(--color-accent-hover);
} }
.btn-danger { .btn-danger {
background: #ef4444; background: var(--color-danger);
color: white; color: white;
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
background: #dc2626; background: color-mix(in srgb, var(--color-danger) 85%, black);
}
.btn-secondary {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border);
} }
.btn-sm { .btn-sm {
@@ -591,18 +596,19 @@ onMounted(() => {
} }
.modal-content { .modal-content {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: var(--shadow-lg);
} }
.modal-content h3 { .modal-content h3 {
margin: 0 0 20px 0; margin: 0 0 20px 0;
color: #374151; color: var(--color-text-primary);
} }
.form-group { .form-group {
@@ -613,14 +619,14 @@ onMounted(() => {
display: block; display: block;
margin-bottom: 6px; margin-bottom: 6px;
font-weight: 500; font-weight: 500;
color: #374151; color: var(--color-text-primary);
} }
.form-label { .form-label {
display: block; display: block;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 500; font-weight: 500;
color: #374151; color: var(--color-text-primary);
font-size: 14px; font-size: 14px;
} }
@@ -628,7 +634,7 @@ onMounted(() => {
display: block; display: block;
margin-bottom: 4px; margin-bottom: 4px;
font-weight: 400; font-weight: 400;
color: #6b7280; color: var(--color-text-secondary);
font-size: 12px; font-size: 12px;
} }
@@ -647,17 +653,24 @@ onMounted(() => {
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #d1d5db; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
font-family: inherit; font-family: inherit;
background: var(--color-surface);
color: var(--color-text-primary);
} }
.form-group input:focus, .form-group input:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: #3b82f6; border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.form-group input:disabled {
background: var(--color-bg-tertiary);
cursor: not-allowed;
} }
.form-group textarea { .form-group textarea {
@@ -666,9 +679,9 @@ onMounted(() => {
.error-message { .error-message {
padding: 12px; padding: 12px;
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: #991b1b; color: var(--color-danger);
border-radius: 4px; border-radius: var(--radius-sm);
margin-bottom: 16px; margin-bottom: 16px;
font-size: 14px; font-size: 14px;
} }
@@ -679,15 +692,6 @@ onMounted(() => {
justify-content: flex-end; justify-content: flex-end;
} }
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #4b5563;
}
/* Mobile Cards */ /* Mobile Cards */
.bookings-cards { .bookings-cards {
display: none; display: none;
@@ -705,10 +709,10 @@ onMounted(() => {
} }
.booking-card { .booking-card {
border: 1px solid #e5e7eb; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: var(--radius-md);
padding: 16px; padding: 16px;
background: #fafafa; background: var(--color-bg-secondary);
} }
.booking-header { .booking-header {
@@ -717,13 +721,13 @@ onMounted(() => {
align-items: start; align-items: start;
margin-bottom: 12px; margin-bottom: 12px;
padding-bottom: 12px; padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid var(--color-border);
} }
.booking-header h3 { .booking-header h3 {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
color: #374151; color: var(--color-text-primary);
} }
.booking-details { .booking-details {
@@ -737,16 +741,17 @@ onMounted(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 14px; font-size: 14px;
color: var(--color-text-primary);
} }
.booking-row .label { .booking-row .label {
font-weight: 500; font-weight: 500;
color: #6b7280; color: var(--color-text-secondary);
} }
.booking-actions { .booking-actions {
padding-top: 12px; padding-top: 12px;
border-top: 1px solid #e5e7eb; border-top: 1px solid var(--color-border);
} }
} }
</style> </style>

View File

@@ -145,12 +145,12 @@ const handleRegister = async () => {
h2 { h2 {
text-align: center; text-align: center;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #2c3e50; color: var(--color-text-primary);
} }
.subtitle { .subtitle {
text-align: center; text-align: center;
color: #7f8c8d; color: var(--color-text-secondary);
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -158,7 +158,7 @@ h2 {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;
font-size: 0.85rem; font-size: 0.85rem;
color: #7f8c8d; color: var(--color-text-secondary);
} }
.btn-block { .btn-block {
@@ -169,11 +169,11 @@ h2 {
.login-link { .login-link {
text-align: center; text-align: center;
margin-top: 1.5rem; margin-top: 1.5rem;
color: #7f8c8d; color: var(--color-text-secondary);
} }
.login-link a { .login-link a {
color: #3498db; color: var(--color-accent);
text-decoration: none; text-decoration: none;
} }
@@ -184,18 +184,18 @@ h2 {
.error { .error {
margin-top: 1rem; margin-top: 1rem;
padding: 0.75rem; padding: 0.75rem;
background: #fee; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-left: 3px solid #e74c3c; border-left: 3px solid var(--color-danger);
border-radius: 4px; border-radius: var(--radius-sm);
color: #c0392b; color: var(--color-danger);
} }
.success { .success {
margin-top: 1rem; margin-top: 1rem;
padding: 0.75rem; padding: 0.75rem;
background: #d4edda; background: color-mix(in srgb, var(--color-success) 10%, transparent);
border-left: 3px solid #28a745; border-left: 3px solid var(--color-success);
border-radius: 4px; border-radius: var(--radius-sm);
color: #155724; color: var(--color-success);
} }
</style> </style>

View File

@@ -3,9 +3,7 @@
<h2>Global Booking Settings</h2> <h2>Global Booking Settings</h2>
<!-- Settings Form --> <!-- Settings Form -->
<div class="card"> <CollapsibleSection title="Booking Rules Configuration" :icon="Sliders">
<h3>Booking Rules Configuration</h3>
<div v-if="loadingSettings" class="loading">Loading settings...</div> <div v-if="loadingSettings" class="loading">Loading settings...</div>
<form v-else @submit.prevent="handleSubmit" class="settings-form"> <form v-else @submit.prevent="handleSubmit" class="settings-form">
@@ -108,25 +106,26 @@
<div v-if="error" class="error">{{ error }}</div> <div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div> <div v-if="success" class="success">{{ success }}</div>
</div> </CollapsibleSection>
<!-- Info Card --> <!-- Info Card -->
<div class="card info-card"> <CollapsibleSection title="About These Settings" :icon="Info">
<h4>About These Settings</h4> <ul class="info-list">
<ul>
<li><strong>Duration:</strong> Controls minimum and maximum booking length</li> <li><strong>Duration:</strong> Controls minimum and maximum booking length</li>
<li><strong>Working Hours:</strong> Bookings outside these hours will be rejected</li> <li><strong>Working Hours:</strong> Bookings outside these hours will be rejected</li>
<li><strong>Max Bookings:</strong> Limits how many bookings a user can make per day</li> <li><strong>Max Bookings:</strong> Limits how many bookings a user can make per day</li>
<li><strong>Cancel Policy:</strong> Users cannot cancel bookings too close to start time</li> <li><strong>Cancel Policy:</strong> Users cannot cancel bookings too close to start time</li>
</ul> </ul>
<p class="note">These rules apply to all new booking requests.</p> <p class="note">These rules apply to all new booking requests.</p>
</div> </CollapsibleSection>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { settingsApi, handleApiError } from '@/services/api' import { settingsApi, handleApiError } from '@/services/api'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Sliders, Info } from 'lucide-vue-next'
import type { Settings } from '@/types' import type { Settings } from '@/types'
const loadingSettings = ref(true) const loadingSettings = ref(true)
@@ -163,7 +162,6 @@ const loadSettings = async () => {
} }
const validateForm = (): boolean => { const validateForm = (): boolean => {
// Validate all fields are positive
if ( if (
formData.value.min_duration_minutes <= 0 || formData.value.min_duration_minutes <= 0 ||
formData.value.max_duration_minutes <= 0 || formData.value.max_duration_minutes <= 0 ||
@@ -176,19 +174,16 @@ const validateForm = (): boolean => {
return false return false
} }
// Validate min < max duration
if (formData.value.min_duration_minutes >= formData.value.max_duration_minutes) { if (formData.value.min_duration_minutes >= formData.value.max_duration_minutes) {
error.value = 'Minimum duration must be less than maximum duration' error.value = 'Minimum duration must be less than maximum duration'
return false return false
} }
// Validate working hours start < end
if (formData.value.working_hours_start >= formData.value.working_hours_end) { if (formData.value.working_hours_start >= formData.value.working_hours_end) {
error.value = 'Working hours start must be less than working hours end' error.value = 'Working hours start must be less than working hours end'
return false return false
} }
// Validate working hours are within 0-23 for start and 1-24 for end
if (formData.value.working_hours_start < 0 || formData.value.working_hours_start > 23) { if (formData.value.working_hours_start < 0 || formData.value.working_hours_start > 23) {
error.value = 'Working hours start must be between 0 and 23' error.value = 'Working hours start must be between 0 and 23'
return false return false
@@ -206,7 +201,6 @@ const handleSubmit = async () => {
error.value = '' error.value = ''
success.value = '' success.value = ''
// Client-side validation
if (!validateForm()) { if (!validateForm()) {
return return
} }
@@ -239,21 +233,7 @@ onMounted(() => {
h2 { h2 {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
color: #333; color: var(--color-text-primary);
}
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h3, h4 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #444;
} }
.settings-form { .settings-form {
@@ -276,18 +256,27 @@ h3, h4 {
.form-group label { .form-group label {
font-weight: 500; font-weight: 500;
color: #555; color: var(--color-text-primary);
font-size: 14px;
} }
.form-group input { .form-group input {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 1rem; font-size: 1rem;
background: var(--color-surface);
color: var(--color-text-primary);
}
.form-group input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
} }
.form-group small { .form-group small {
color: #666; color: var(--color-text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
} }
@@ -300,19 +289,19 @@ h3, h4 {
.btn { .btn {
padding: 0.6rem 1.2rem; padding: 0.6rem 1.2rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: all var(--transition-fast);
} }
.btn-primary { .btn-primary {
background-color: #4caf50; background: var(--color-accent);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background-color: #45a049; background: var(--color-accent-hover);
} }
.btn:disabled { .btn:disabled {
@@ -323,46 +312,48 @@ h3, h4 {
.loading { .loading {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
color: #666; color: var(--color-text-secondary);
} }
.error { .error {
padding: 0.75rem; padding: 0.75rem;
background-color: #fee; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid #fcc; border-radius: var(--radius-sm);
border-radius: 4px; color: var(--color-danger);
color: #c33;
margin-top: 1rem; margin-top: 1rem;
} }
.success { .success {
padding: 0.75rem; padding: 0.75rem;
background-color: #efe; background: color-mix(in srgb, var(--color-success) 10%, transparent);
border: 1px solid #cfc; border-radius: var(--radius-sm);
border-radius: 4px; color: var(--color-success);
color: #3c3;
margin-top: 1rem; margin-top: 1rem;
} }
.info-card { .info-list {
background-color: #f8f9fa;
}
.info-card ul {
margin: 0; margin: 0;
padding-left: 1.5rem; padding-left: 1.5rem;
} }
.info-card li { .info-list li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #555; color: var(--color-text-secondary);
}
.info-list strong {
color: var(--color-text-primary);
} }
.note { .note {
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 0; margin-bottom: 0;
font-style: italic; font-style: italic;
color: #666; color: var(--color-text-secondary);
}
.collapsible-section + .collapsible-section {
margin-top: 16px;
} }
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@@ -33,14 +33,7 @@
<div class="space-meta"> <div class="space-meta">
<span class="badge badge-type">{{ formatType(space.type) }}</span> <span class="badge badge-type">{{ formatType(space.type) }}</span>
<span class="badge badge-capacity"> <span class="badge badge-capacity">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Users :size="16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
Capacity: {{ space.capacity }} Capacity: {{ space.capacity }}
</span> </span>
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']"> <span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
@@ -53,14 +46,7 @@
:disabled="!space.is_active" :disabled="!space.is_active"
@click="handleReserve" @click="handleReserve"
> >
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Plus :size="18" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{{ showBookingForm ? 'Cancel Reservation' : 'Reserve Space' }} {{ showBookingForm ? 'Cancel Reservation' : 'Reserve Space' }}
</button> </button>
</div> </div>
@@ -99,6 +85,7 @@ import { useRoute } from 'vue-router'
import { spacesApi, handleApiError } from '@/services/api' import { spacesApi, handleApiError } from '@/services/api'
import SpaceCalendar from '@/components/SpaceCalendar.vue' import SpaceCalendar from '@/components/SpaceCalendar.vue'
import BookingForm from '@/components/BookingForm.vue' import BookingForm from '@/components/BookingForm.vue'
import { Users, Plus } from 'lucide-vue-next'
import type { Space } from '@/types' import type { Space } from '@/types'
const route = useRoute() const route = useRoute()
@@ -132,7 +119,6 @@ const loadSpace = async () => {
} }
// Fetch all spaces and filter by ID // Fetch all spaces and filter by ID
// (Could be optimized with a dedicated GET /api/spaces/:id endpoint in the future)
const spaces = await spacesApi.list() const spaces = await spacesApi.list()
const foundSpace = spaces.find((s) => s.id === spaceId) const foundSpace = spaces.find((s) => s.id === spaceId)
@@ -170,11 +156,6 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.space-detail {
max-width: 1200px;
margin: 0 auto;
}
/* Breadcrumbs */ /* Breadcrumbs */
.breadcrumbs { .breadcrumbs {
display: flex; display: flex;
@@ -182,26 +163,26 @@ onMounted(() => {
gap: 8px; gap: 8px;
margin-bottom: 24px; margin-bottom: 24px;
font-size: 14px; font-size: 14px;
color: #6b7280; color: var(--color-text-secondary);
} }
.breadcrumbs a { .breadcrumbs a {
color: #3b82f6; color: var(--color-accent);
text-decoration: none; text-decoration: none;
transition: color 0.2s; transition: color var(--transition-fast);
} }
.breadcrumbs a:hover { .breadcrumbs a:hover {
color: #2563eb; color: var(--color-accent-hover);
text-decoration: underline; text-decoration: underline;
} }
.breadcrumbs .separator { .breadcrumbs .separator {
color: #9ca3af; color: var(--color-text-muted);
} }
.breadcrumbs .current { .breadcrumbs .current {
color: #374151; color: var(--color-text-primary);
font-weight: 500; font-weight: 500;
} }
@@ -212,14 +193,14 @@ onMounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 80px 20px; padding: 80px 20px;
color: #6b7280; color: var(--color-text-secondary);
} }
.spinner { .spinner {
width: 48px; width: 48px;
height: 48px; height: 48px;
border: 4px solid #e5e7eb; border: 4px solid var(--color-border);
border-top-color: #3b82f6; border-top-color: var(--color-accent);
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
margin-bottom: 16px; margin-bottom: 16px;
@@ -239,21 +220,22 @@ onMounted(() => {
} }
.error-card { .error-card {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 40px; padding: 40px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
text-align: center; text-align: center;
max-width: 500px; max-width: 500px;
} }
.error-card h3 { .error-card h3 {
color: #991b1b; color: var(--color-danger);
margin-bottom: 12px; margin-bottom: 12px;
} }
.error-card p { .error-card p {
color: #6b7280; color: var(--color-text-secondary);
margin-bottom: 24px; margin-bottom: 24px;
} }
@@ -281,7 +263,7 @@ onMounted(() => {
.header-info h1 { .header-info h1 {
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
color: #111827; color: var(--color-text-primary);
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -303,42 +285,37 @@ onMounted(() => {
} }
.badge-type { .badge-type {
background: #dbeafe; background: var(--color-accent-light);
color: #1e40af; color: var(--color-accent);
} }
.badge-capacity { .badge-capacity {
background: #f3f4f6; background: var(--color-bg-tertiary);
color: #374151; color: var(--color-text-primary);
} }
.badge-active { .badge-active {
background: #d1fae5; background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: #065f46; color: var(--color-success);
} }
.badge-inactive { .badge-inactive {
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: #991b1b; color: var(--color-danger);
} }
.icon { /* Buttons */
width: 18px;
height: 18px;
}
/* Reserve Button */
.btn { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 12px 24px; padding: 12px 24px;
border: none; border: none;
border-radius: 8px; border-radius: var(--radius-md);
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all var(--transition-fast);
white-space: nowrap; white-space: nowrap;
} }
@@ -348,14 +325,14 @@ onMounted(() => {
} }
.btn-primary { .btn-primary {
background: #3b82f6; background: var(--color-accent);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: #2563eb; background: var(--color-accent-hover);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); box-shadow: var(--shadow-md);
} }
.btn-reserve { .btn-reserve {
@@ -365,33 +342,29 @@ onMounted(() => {
/* Cards */ /* Cards */
.card { .card {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
} }
.card h3 { .card h3 {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: #111827; color: var(--color-text-primary);
margin-bottom: 12px; margin-bottom: 12px;
} }
/* Description Card */ /* Description Card */
.description-card p { .description-card p {
color: #4b5563; color: var(--color-text-secondary);
line-height: 1.6; line-height: 1.6;
} }
/* Booking Card */
.booking-card h3 {
margin-bottom: 16px;
}
/* Calendar Card */ /* Calendar Card */
.calendar-subtitle { .calendar-subtitle {
color: #6b7280; color: var(--color-text-secondary);
font-size: 14px; font-size: 14px;
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -411,14 +384,14 @@ onMounted(() => {
} }
.modal-content { .modal-content {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
max-width: 600px; max-width: 600px;
width: 90%; width: 90%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-lg);
} }
.modal-content h3 { .modal-content h3 {

View File

@@ -42,14 +42,7 @@
<!-- Empty State --> <!-- Empty State -->
<div v-else-if="filteredSpaces.length === 0" class="empty-state"> <div v-else-if="filteredSpaces.length === 0" class="empty-state">
<svg class="empty-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Building2 :size="80" class="empty-icon" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<h3>No Spaces Found</h3> <h3>No Spaces Found</h3>
<p>{{ selectedType || selectedStatus ? 'Try adjusting your filters' : 'No spaces are currently available' }}</p> <p>{{ selectedType || selectedStatus ? 'Try adjusting your filters' : 'No spaces are currently available' }}</p>
</div> </div>
@@ -72,27 +65,13 @@
<div class="space-card-body"> <div class="space-card-body">
<div class="space-info"> <div class="space-info">
<div class="info-item"> <div class="info-item">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Tag :size="18" class="info-icon" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<span class="label">Type:</span> <span class="label">Type:</span>
<span class="value">{{ formatType(space.type) }}</span> <span class="value">{{ formatType(space.type) }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Users :size="18" class="info-icon" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<span class="label">Capacity:</span> <span class="label">Capacity:</span>
<span class="value">{{ space.capacity }} {{ space.capacity === 1 ? 'person' : 'people' }}</span> <span class="value">{{ space.capacity }} {{ space.capacity === 1 ? 'person' : 'people' }}</span>
</div> </div>
@@ -106,14 +85,7 @@
<div class="space-card-footer"> <div class="space-card-footer">
<button class="btn btn-secondary"> <button class="btn btn-secondary">
View Details View Details
<svg class="icon-small" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <ChevronRight :size="16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
@@ -125,6 +97,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { spacesApi, handleApiError } from '@/services/api' import { spacesApi, handleApiError } from '@/services/api'
import { Building2, Tag, Users, ChevronRight } from 'lucide-vue-next'
import type { Space } from '@/types' import type { Space } from '@/types'
const router = useRouter() const router = useRouter()
@@ -194,11 +167,6 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.spaces {
max-width: 1400px;
margin: 0 auto;
}
/* Header */ /* Header */
.spaces-header { .spaces-header {
display: flex; display: flex;
@@ -210,12 +178,12 @@ onMounted(() => {
.spaces-header h2 { .spaces-header h2 {
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
color: #111827; color: var(--color-text-primary);
margin-bottom: 8px; margin-bottom: 8px;
} }
.subtitle { .subtitle {
color: #6b7280; color: var(--color-text-secondary);
font-size: 16px; font-size: 16px;
} }
@@ -236,28 +204,27 @@ onMounted(() => {
.filter-group label { .filter-group label {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: #374151; color: var(--color-text-primary);
} }
.filter-select { .filter-select {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #d1d5db; border: 1px solid var(--color-border);
border-radius: 6px; border-radius: var(--radius-sm);
background: white; background: var(--color-surface);
font-size: 14px; font-size: 14px;
color: #374151; color: var(--color-text-primary);
cursor: pointer; cursor: pointer;
transition: border-color 0.2s; transition: border-color var(--transition-fast);
} }
.filter-select:hover { .filter-select:hover {
border-color: #9ca3af; border-color: var(--color-text-muted);
} }
.filter-select:focus { .filter-select:focus {
outline: none; outline: none;
border-color: #3b82f6; border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
} }
/* Loading State */ /* Loading State */
@@ -267,14 +234,14 @@ onMounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 80px 20px; padding: 80px 20px;
color: #6b7280; color: var(--color-text-secondary);
} }
.spinner { .spinner {
width: 48px; width: 48px;
height: 48px; height: 48px;
border: 4px solid #e5e7eb; border: 4px solid var(--color-border);
border-top-color: #3b82f6; border-top-color: var(--color-accent);
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
margin-bottom: 16px; margin-bottom: 16px;
@@ -288,22 +255,23 @@ onMounted(() => {
/* Error State */ /* Error State */
.error-card { .error-card {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 40px; padding: 40px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
text-align: center; text-align: center;
max-width: 500px; max-width: 500px;
margin: 40px auto; margin: 40px auto;
} }
.error-card h3 { .error-card h3 {
color: #991b1b; color: var(--color-danger);
margin-bottom: 12px; margin-bottom: 12px;
} }
.error-card p { .error-card p {
color: #6b7280; color: var(--color-text-secondary);
margin-bottom: 24px; margin-bottom: 24px;
} }
@@ -314,21 +282,20 @@ onMounted(() => {
} }
.empty-icon { .empty-icon {
width: 80px; color: var(--color-border);
height: 80px;
color: #d1d5db;
margin: 0 auto 24px; margin: 0 auto 24px;
display: block;
} }
.empty-state h3 { .empty-state h3 {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: #374151; color: var(--color-text-primary);
margin-bottom: 8px; margin-bottom: 8px;
} }
.empty-state p { .empty-state p {
color: #6b7280; color: var(--color-text-secondary);
font-size: 16px; font-size: 16px;
} }
@@ -341,19 +308,20 @@ onMounted(() => {
/* Space Card */ /* Space Card */
.space-card { .space-card {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all var(--transition-fast);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.space-card:hover { .space-card:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-lg);
} }
.space-card-header { .space-card-header {
@@ -367,7 +335,7 @@ onMounted(() => {
.space-card-header h3 { .space-card-header h3 {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: #111827; color: var(--color-text-primary);
flex: 1; flex: 1;
} }
@@ -375,20 +343,20 @@ onMounted(() => {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 4px 12px; padding: 4px 12px;
border-radius: 12px; border-radius: var(--radius-lg);
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
} }
.badge-active { .badge-active {
background: #d1fae5; background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: #065f46; color: var(--color-success);
} }
.badge-inactive { .badge-inactive {
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: #991b1b; color: var(--color-danger);
} }
.space-card-body { .space-card-body {
@@ -408,27 +376,25 @@ onMounted(() => {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 14px; font-size: 14px;
color: #4b5563; color: var(--color-text-secondary);
} }
.icon { .info-icon {
width: 18px; color: var(--color-text-muted);
height: 18px;
color: #9ca3af;
flex-shrink: 0; flex-shrink: 0;
} }
.label { .label {
font-weight: 500; font-weight: 500;
color: #6b7280; color: var(--color-text-secondary);
} }
.value { .value {
color: #374151; color: var(--color-text-primary);
} }
.space-description { .space-description {
color: #6b7280; color: var(--color-text-secondary);
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
margin-top: 12px; margin-top: 12px;
@@ -446,34 +412,29 @@ onMounted(() => {
gap: 8px; gap: 8px;
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
border-radius: 6px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all var(--transition-fast);
} }
.btn-primary { .btn-primary {
background: #3b82f6; background: var(--color-accent);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background: #2563eb; background: var(--color-accent-hover);
} }
.btn-secondary { .btn-secondary {
background: #f3f4f6; background: var(--color-bg-secondary);
color: #374151; color: var(--color-text-primary);
} }
.btn-secondary:hover { .btn-secondary:hover {
background: #e5e7eb; background: var(--color-bg-tertiary);
}
.icon-small {
width: 16px;
height: 16px;
} }
/* Responsive */ /* Responsive */

View File

@@ -3,8 +3,7 @@
<h2>User Profile</h2> <h2>User Profile</h2>
<!-- Profile Information Card --> <!-- Profile Information Card -->
<div class="card"> <CollapsibleSection title="Profile Information" :icon="UserIcon">
<h3>Profile Information</h3>
<div v-if="user" class="profile-info"> <div v-if="user" class="profile-info">
<div class="info-item"> <div class="info-item">
<label>Email:</label> <label>Email:</label>
@@ -23,12 +22,10 @@
<span>{{ user.role }}</span> <span>{{ user.role }}</span>
</div> </div>
</div> </div>
</div> </CollapsibleSection>
<!-- Timezone Preferences Card --> <!-- Timezone Preferences Card -->
<div class="card"> <CollapsibleSection title="Timezone Preferences" :icon="Globe">
<h3>Timezone Preferences</h3>
<div v-if="loadingTimezones" class="loading">Loading timezones...</div> <div v-if="loadingTimezones" class="loading">Loading timezones...</div>
<div v-else class="timezone-settings"> <div v-else class="timezone-settings">
@@ -56,18 +53,16 @@
<div v-if="timezoneSuccess" class="success">{{ timezoneSuccess }}</div> <div v-if="timezoneSuccess" class="success">{{ timezoneSuccess }}</div>
<div v-if="timezoneError" class="error">{{ timezoneError }}</div> <div v-if="timezoneError" class="error">{{ timezoneError }}</div>
</div> </div>
</div> </CollapsibleSection>
<!-- Google Calendar Integration Card --> <!-- Google Calendar Integration Card -->
<div class="card"> <CollapsibleSection title="Google Calendar Integration" :icon="CalendarDays">
<h3>Google Calendar Integration</h3>
<div v-if="loadingGoogleStatus" class="loading">Checking connection status...</div> <div v-if="loadingGoogleStatus" class="loading">Checking connection status...</div>
<div v-else> <div v-else>
<div v-if="googleStatus.connected" class="google-connected"> <div v-if="googleStatus.connected" class="google-connected">
<div class="status-indicator"> <div class="status-indicator">
<span class="status-icon"></span> <CheckCircle :size="20" class="status-icon-connected" />
<span>Connected to Google Calendar</span> <span>Connected to Google Calendar</span>
</div> </div>
@@ -103,12 +98,11 @@
<div v-if="error" class="error">{{ error }}</div> <div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div> <div v-if="success" class="success">{{ success }}</div>
</div> </CollapsibleSection>
<!-- Info Card --> <!-- Info Card -->
<div class="card info-card"> <CollapsibleSection title="About Calendar Integration" :icon="Info">
<h4>About Calendar Integration</h4> <ul class="info-list">
<ul>
<li> <li>
<strong>Automatic Sync:</strong> When your booking is approved, it's automatically added to <strong>Automatic Sync:</strong> When your booking is approved, it's automatically added to
your Google Calendar your Google Calendar
@@ -122,7 +116,7 @@
above above
</li> </li>
</ul> </ul>
</div> </CollapsibleSection>
</div> </div>
</template> </template>
@@ -131,6 +125,8 @@ import { ref, computed, onMounted } from 'vue'
import { usersApi, googleCalendarApi, handleApiError } from '@/services/api' import { usersApi, googleCalendarApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime' import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { User as UserIcon, Globe, CalendarDays, CheckCircle, Info } from 'lucide-vue-next'
import type { User } from '@/types' import type { User } from '@/types'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -184,7 +180,6 @@ const updateTimezone = async () => {
try { try {
await usersApi.updateTimezone(selectedTimezone.value) await usersApi.updateTimezone(selectedTimezone.value)
// Update auth store
if (authStore.user) { if (authStore.user) {
authStore.user.timezone = selectedTimezone.value authStore.user.timezone = selectedTimezone.value
} }
@@ -195,7 +190,6 @@ const updateTimezone = async () => {
}, 5000) }, 5000)
} catch (err) { } catch (err) {
timezoneError.value = handleApiError(err) timezoneError.value = handleApiError(err)
// Revert selection on error
if (user.value) { if (user.value) {
selectedTimezone.value = user.value.timezone selectedTimezone.value = user.value.timezone
} }
@@ -223,21 +217,17 @@ const connectGoogle = async () => {
try { try {
const response = await googleCalendarApi.connect() const response = await googleCalendarApi.connect()
// Open OAuth URL in popup window
const popup = window.open( const popup = window.open(
response.authorization_url, response.authorization_url,
'Google Calendar Authorization', 'Google Calendar Authorization',
'width=600,height=600,toolbar=no,menubar=no,location=no' 'width=600,height=600,toolbar=no,menubar=no,location=no'
) )
// Poll for connection status
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
// Check if popup was closed
if (popup && popup.closed) { if (popup && popup.closed) {
clearInterval(pollInterval) clearInterval(pollInterval)
connecting.value = false connecting.value = false
// Check status one more time
await checkGoogleStatus() await checkGoogleStatus()
if (googleStatus.value.connected) { if (googleStatus.value.connected) {
@@ -247,7 +237,6 @@ const connectGoogle = async () => {
}, 3000) }, 3000)
} }
} else { } else {
// Poll for connection status
try { try {
const status = await googleCalendarApi.status() const status = await googleCalendarApi.status()
if (status.connected) { if (status.connected) {
@@ -256,7 +245,6 @@ const connectGoogle = async () => {
googleStatus.value = status googleStatus.value = status
success.value = 'Google Calendar connected successfully!' success.value = 'Google Calendar connected successfully!'
// Close popup
if (popup) { if (popup) {
popup.close() popup.close()
} }
@@ -271,7 +259,6 @@ const connectGoogle = async () => {
} }
}, 2000) }, 2000)
// Stop polling after 5 minutes
setTimeout(() => { setTimeout(() => {
clearInterval(pollInterval) clearInterval(pollInterval)
connecting.value = false connecting.value = false
@@ -328,22 +315,7 @@ onMounted(() => {
h2 { h2 {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
color: #333; color: var(--color-text-primary);
}
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h3,
h4 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #444;
} }
.profile-info { .profile-info {
@@ -360,17 +332,17 @@ h4 {
.info-item label { .info-item label {
font-weight: 600; font-weight: 600;
min-width: 120px; min-width: 120px;
color: #555; color: var(--color-text-secondary);
} }
.info-item span { .info-item span {
color: #333; color: var(--color-text-primary);
} }
.loading { .loading {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
color: #666; color: var(--color-text-secondary);
} }
.google-connected, .google-connected,
@@ -386,29 +358,21 @@ h4 {
gap: 0.5rem; gap: 0.5rem;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 500; font-weight: 500;
color: #4caf50; color: var(--color-success);
} }
.status-icon { .status-icon-connected {
display: inline-flex; color: var(--color-success);
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background-color: #4caf50;
color: white;
border-radius: 50%;
font-size: 0.9rem;
} }
.expiry-info { .expiry-info {
color: #666; color: var(--color-text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
margin: 0; margin: 0;
} }
.info-text { .info-text {
color: #555; color: var(--color-text-secondary);
line-height: 1.6; line-height: 1.6;
margin: 0; margin: 0;
} }
@@ -420,35 +384,35 @@ h4 {
.benefits-list li { .benefits-list li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #555; color: var(--color-text-secondary);
} }
.btn { .btn {
padding: 0.6rem 1.2rem; padding: 0.6rem 1.2rem;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: all var(--transition-fast);
align-self: flex-start; align-self: flex-start;
} }
.btn-primary { .btn-primary {
background-color: #4285f4; background: var(--color-accent);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background-color: #357ae8; background: var(--color-accent-hover);
} }
.btn-danger { .btn-danger {
background-color: #dc3545; background: var(--color-danger);
color: white; color: white;
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
background-color: #c82333; background: color-mix(in srgb, var(--color-danger) 85%, black);
} }
.btn:disabled { .btn:disabled {
@@ -458,34 +422,32 @@ h4 {
.error { .error {
padding: 0.75rem; padding: 0.75rem;
background-color: #fee; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid #fcc; border-radius: var(--radius-sm);
border-radius: 4px; color: var(--color-danger);
color: #c33;
margin-top: 1rem; margin-top: 1rem;
} }
.success { .success {
padding: 0.75rem; padding: 0.75rem;
background-color: #efe; background: color-mix(in srgb, var(--color-success) 10%, transparent);
border: 1px solid #cfc; border-radius: var(--radius-sm);
border-radius: 4px; color: var(--color-success);
color: #3c3;
margin-top: 1rem; margin-top: 1rem;
} }
.info-card { .info-list {
background-color: #f8f9fa;
}
.info-card ul {
margin: 0; margin: 0;
padding-left: 1.5rem; padding-left: 1.5rem;
} }
.info-card li { .info-list li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #555; color: var(--color-text-secondary);
}
.info-list strong {
color: var(--color-text-primary);
} }
.timezone-settings { .timezone-settings {
@@ -502,16 +464,18 @@ h4 {
.form-group label { .form-group label {
font-weight: 600; font-weight: 600;
color: #555; color: var(--color-text-secondary);
} }
.timezone-select { .timezone-select {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 1rem; font-size: 1rem;
max-width: 400px; max-width: 400px;
cursor: pointer; cursor: pointer;
background: var(--color-surface);
color: var(--color-text-primary);
} }
.timezone-select:disabled { .timezone-select:disabled {
@@ -520,9 +484,13 @@ h4 {
} }
.help-text { .help-text {
color: #666; color: var(--color-text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
font-style: italic; font-style: italic;
margin: 0; margin: 0;
} }
.collapsible-section + .collapsible-section {
margin-top: 16px;
}
</style> </style>

View File

@@ -3,13 +3,13 @@
<div class="page-header"> <div class="page-header">
<h2>Admin Dashboard - User Management</h2> <h2>Admin Dashboard - User Management</h2>
<button class="btn btn-primary" @click="openCreateModal"> <button class="btn btn-primary" @click="openCreateModal">
<UserPlus :size="16" />
Create New User Create New User
</button> </button>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="card"> <CollapsibleSection title="Filters" :icon="Filter">
<h3>Filters</h3>
<div class="filters"> <div class="filters">
<div class="form-group"> <div class="form-group">
<label for="filter-role">Filter by Role</label> <label for="filter-role">Filter by Role</label>
@@ -31,16 +31,16 @@
/> />
</div> </div>
</div> </div>
</div> </CollapsibleSection>
<!-- Users List --> <!-- Users List -->
<div class="card"> <CollapsibleSection title="All Users" :icon="UsersIcon">
<h3>All Users</h3>
<div v-if="loadingUsers" class="loading">Loading users...</div> <div v-if="loadingUsers" class="loading">Loading users...</div>
<div v-else-if="users.length === 0" class="empty"> <div v-else-if="users.length === 0" class="empty">
No users found. {{ filterRole || filterOrganization ? 'Try different filters.' : 'Create one above!' }} No users found. {{ filterRole || filterOrganization ? 'Try different filters.' : 'Create one above!' }}
</div> </div>
<table v-else class="users-table"> <div v-else class="table-responsive">
<table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Email</th> <th>Email</th>
@@ -93,6 +93,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</CollapsibleSection>
<!-- Create/Edit User Modal --> <!-- Create/Edit User Modal -->
<div v-if="showFormModal" class="modal" @click.self="closeFormModal"> <div v-if="showFormModal" class="modal" @click.self="closeFormModal">
@@ -200,6 +201,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { usersApi, handleApiError } from '@/services/api' import { usersApi, handleApiError } from '@/services/api'
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import { Users as UsersIcon, UserPlus, Filter } from 'lucide-vue-next'
import type { User } from '@/types' import type { User } from '@/types'
const users = ref<User[]>([]) const users = ref<User[]>([])
@@ -367,11 +370,6 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.users {
max-width: 1400px;
margin: 0 auto;
}
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -383,20 +381,7 @@ onMounted(() => {
.page-header h2 { .page-header h2 {
margin: 0; margin: 0;
} color: var(--color-text-primary);
.card {
background: white;
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.user-form {
display: flex;
flex-direction: column;
gap: 16px;
} }
.filters { .filters {
@@ -405,6 +390,12 @@ onMounted(() => {
gap: 16px; gap: 16px;
} }
.user-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -413,28 +404,31 @@ onMounted(() => {
.form-group label { .form-group label {
font-weight: 500; font-weight: 500;
color: #374151; color: var(--color-text-primary);
font-size: 14px;
} }
.form-group input, .form-group input,
.form-group select, .form-group select,
.form-group textarea { .form-group textarea {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #d1d5db; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
} }
.form-group input:focus, .form-group input:focus,
.form-group select:focus, .form-group select:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: #3b82f6; border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
} }
.form-group input:disabled { .form-group input:disabled {
background: #f3f4f6; background: var(--color-bg-tertiary);
cursor: not-allowed; cursor: not-allowed;
} }
@@ -445,13 +439,16 @@ onMounted(() => {
} }
.btn { .btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all var(--transition-fast);
} }
.btn:disabled { .btn:disabled {
@@ -460,48 +457,48 @@ onMounted(() => {
} }
.btn-primary { .btn-primary {
background: #3b82f6; background: var(--color-accent);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: #2563eb; background: var(--color-accent-hover);
} }
.btn-secondary { .btn-secondary {
background: #6b7280; background: var(--color-bg-tertiary);
color: white; color: var(--color-text-primary);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: #4b5563; background: var(--color-border);
} }
.btn-success { .btn-success {
background: #10b981; background: var(--color-success);
color: white; color: white;
} }
.btn-success:hover:not(:disabled) { .btn-success:hover:not(:disabled) {
background: #059669; background: color-mix(in srgb, var(--color-success) 85%, black);
} }
.btn-warning { .btn-warning {
background: #f59e0b; background: var(--color-warning);
color: white; color: white;
} }
.btn-warning:hover:not(:disabled) { .btn-warning:hover:not(:disabled) {
background: #d97706; background: color-mix(in srgb, var(--color-warning) 85%, black);
} }
.btn-danger { .btn-danger {
background: #ef4444; background: var(--color-danger);
color: white; color: white;
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
background: #dc2626; background: color-mix(in srgb, var(--color-danger) 85%, black);
} }
.btn-sm { .btn-sm {
@@ -511,53 +508,58 @@ onMounted(() => {
.error { .error {
padding: 12px; padding: 12px;
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: #991b1b; color: var(--color-danger);
border-radius: 4px; border-radius: var(--radius-sm);
margin-top: 12px; margin-top: 12px;
} }
.success { .success {
padding: 12px; padding: 12px;
background: #d1fae5; background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: #065f46; color: var(--color-success);
border-radius: 4px; border-radius: var(--radius-sm);
margin-top: 12px; margin-top: 12px;
} }
.loading { .loading {
text-align: center; text-align: center;
color: #6b7280; color: var(--color-text-secondary);
padding: 24px; padding: 24px;
} }
.empty { .empty {
text-align: center; text-align: center;
color: #9ca3af; color: var(--color-text-muted);
padding: 24px; padding: 24px;
} }
.users-table { .table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
} }
.users-table th { .data-table th {
text-align: left; text-align: left;
padding: 12px; padding: 12px;
background: #f9fafb; background: var(--color-bg-secondary);
font-weight: 600; font-weight: 600;
color: #374151; color: var(--color-text-primary);
border-bottom: 2px solid #e5e7eb; border-bottom: 2px solid var(--color-border);
} }
.users-table td { .data-table td {
padding: 12px; padding: 12px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid var(--color-border);
color: var(--color-text-primary);
} }
.users-table tr:hover { .data-table tr:hover {
background: #f9fafb; background: var(--color-surface-hover);
} }
.badge { .badge {
@@ -569,23 +571,23 @@ onMounted(() => {
} }
.badge-active { .badge-active {
background: #d1fae5; background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: #065f46; color: var(--color-success);
} }
.badge-inactive { .badge-inactive {
background: #fee2e2; background: color-mix(in srgb, var(--color-danger) 15%, transparent);
color: #991b1b; color: var(--color-danger);
} }
.badge-admin { .badge-admin {
background: #dbeafe; background: color-mix(in srgb, var(--color-accent) 15%, transparent);
color: #1e40af; color: var(--color-accent);
} }
.badge-user { .badge-user {
background: #f3f4f6; background: var(--color-bg-tertiary);
color: #374151; color: var(--color-text-primary);
} }
.actions { .actions {
@@ -608,15 +610,31 @@ onMounted(() => {
} }
.modal-content { .modal-content {
background: white; background: var(--color-surface);
border-radius: 8px; border-radius: var(--radius-md);
padding: 24px; padding: 24px;
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-lg);
} }
.modal-content h3 { .modal-content h3 {
margin-bottom: 16px; margin-bottom: 16px;
color: var(--color-text-primary);
}
.collapsible-section + .collapsible-section {
margin-top: 16px;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.actions {
flex-direction: column;
}
} }
</style> </style>

View File

@@ -129,8 +129,8 @@ const resendVerification = async () => {
.spinner { .spinner {
width: 50px; width: 50px;
height: 50px; height: 50px;
border: 4px solid #f3f3f3; border: 4px solid var(--color-border);
border-top: 4px solid #3498db; border-top: 4px solid var(--color-accent);
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
margin: 0 auto 1.5rem; margin: 0 auto 1.5rem;
@@ -153,7 +153,7 @@ const resendVerification = async () => {
.icon-success { .icon-success {
width: 80px; width: 80px;
height: 80px; height: 80px;
background: #28a745; background: var(--color-success);
color: white; color: white;
font-size: 3rem; font-size: 3rem;
line-height: 80px; line-height: 80px;
@@ -164,7 +164,7 @@ const resendVerification = async () => {
.icon-error { .icon-error {
width: 80px; width: 80px;
height: 80px; height: 80px;
background: #e74c3c; background: var(--color-danger);
color: white; color: white;
font-size: 3rem; font-size: 3rem;
line-height: 80px; line-height: 80px;
@@ -173,17 +173,17 @@ const resendVerification = async () => {
} }
h2 { h2 {
color: #2c3e50; color: var(--color-text-primary);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
p { p {
color: #7f8c8d; color: var(--color-text-secondary);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.error-message { .error-message {
color: #c0392b; color: var(--color-danger);
font-weight: 500; font-weight: 500;
} }
@@ -192,7 +192,7 @@ p {
} }
.login-link a { .login-link a {
color: #3498db; color: var(--color-accent);
text-decoration: none; text-decoration: none;
} }
@@ -203,13 +203,13 @@ p {
.resend-section { .resend-section {
margin-top: 2rem; margin-top: 2rem;
padding-top: 2rem; padding-top: 2rem;
border-top: 1px solid #e1e8ed; border-top: 1px solid var(--color-border);
} }
.resend-section > p { .resend-section > p {
margin-bottom: 1rem; margin-bottom: 1rem;
font-weight: 500; font-weight: 500;
color: #2c3e50; color: var(--color-text-primary);
} }
.resend-form { .resend-form {
@@ -221,22 +221,25 @@ p {
.resend-input { .resend-input {
flex: 1; flex: 1;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-sm);
font-size: 1rem; font-size: 1rem;
background: var(--color-surface);
color: var(--color-text-primary);
} }
.resend-input:focus { .resend-input:focus {
outline: none; outline: none;
border-color: #3498db; border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
} }
.resend-message { .resend-message {
padding: 0.75rem; padding: 0.75rem;
background: #d4edda; background: color-mix(in srgb, var(--color-success) 10%, transparent);
border-left: 3px solid #28a745; border-left: 3px solid var(--color-success);
border-radius: 4px; border-radius: var(--radius-sm);
color: #155724; color: var(--color-success);
text-align: left; text-align: left;
} }
</style> </style>