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:
@@ -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
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@fullcalendar/vue3": "^6.1.0",
|
||||
"axios": "^1.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0"
|
||||
@@ -2769,6 +2770,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@fullcalendar/vue3": "^6.1.0",
|
||||
"axios": "^1.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"lucide-vue-next": "^0.563.0",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0"
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<header v-if="authStore.isAuthenticated" class="header">
|
||||
<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>
|
||||
<AppSidebar v-if="authStore.isAuthenticated" />
|
||||
|
||||
<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 -->
|
||||
<div class="notification-wrapper">
|
||||
<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">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
||||
</svg>
|
||||
<span v-if="unreadCount > 0" class="badge">{{ unreadCount }}</span>
|
||||
<Bell :size="20" />
|
||||
<span v-if="unreadCount > 0" class="notification-badge">{{ unreadCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Notification Dropdown -->
|
||||
<div v-if="showNotifications" class="notification-dropdown" ref="dropdownRef">
|
||||
<div class="notification-header">
|
||||
<h3>Notifications</h3>
|
||||
<button @click="closeNotifications" class="close-btn">×</button>
|
||||
<button @click="closeNotifications" class="close-btn">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="notification-loading">Loading...</div>
|
||||
@@ -51,14 +46,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="logout" class="btn-logout">Logout ({{ authStore.user?.email }})</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<main class="content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,10 +61,14 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { notificationsApi } from '@/services/api'
|
||||
import { useSidebar } from '@/composables/useSidebar'
|
||||
import type { Notification } from '@/types'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import { Menu, Bell, X } from 'lucide-vue-next'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const { collapsed, toggleMobile } = useSidebar()
|
||||
|
||||
const notifications = ref<Notification[]>([])
|
||||
const showNotifications = ref(false)
|
||||
@@ -83,17 +81,11 @@ const unreadCount = computed(() => {
|
||||
return notifications.value.filter((n) => !n.is_read).length
|
||||
})
|
||||
|
||||
const logout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
if (!authStore.isAuthenticated) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
// Get all notifications, sorted by created_at DESC (from API)
|
||||
notifications.value = await notificationsApi.getAll()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notifications:', error)
|
||||
@@ -114,18 +106,15 @@ const closeNotifications = () => {
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (notification: Notification) => {
|
||||
// Mark as read
|
||||
if (!notification.is_read) {
|
||||
try {
|
||||
await notificationsApi.markAsRead(notification.id)
|
||||
// Update local state
|
||||
notification.is_read = true
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notification as read:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to booking if available
|
||||
if (notification.booking_id) {
|
||||
closeNotifications()
|
||||
router.push('/my-bookings')
|
||||
@@ -147,25 +136,20 @@ const formatTime = (dateStr: string): string => {
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
// Click outside to close
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (
|
||||
dropdownRef.value &&
|
||||
!dropdownRef.value.contains(event.target as Node) &&
|
||||
!(event.target as HTMLElement).closest('.notification-bell')
|
||||
!dropdownRef.value.contains(target) &&
|
||||
!target.closest('.notification-bell')
|
||||
) {
|
||||
closeNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Initial fetch
|
||||
fetchNotifications()
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
refreshInterval = window.setInterval(fetchNotifications, 30000)
|
||||
|
||||
// Add click outside listener
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
@@ -178,64 +162,68 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
/* Layout */
|
||||
.app-main {
|
||||
min-height: 100vh;
|
||||
transition: margin-left var(--transition-normal);
|
||||
}
|
||||
|
||||
.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;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
/* Mobile header */
|
||||
.mobile-header {
|
||||
display: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
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 {
|
||||
margin: 0;
|
||||
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;
|
||||
.mobile-hamburger {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-primary);
|
||||
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 {
|
||||
background: #c0392b;
|
||||
.mobile-hamburger:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.main {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
.mobile-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mobile-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
@@ -246,44 +234,47 @@ nav a.router-link-active {
|
||||
.notification-bell {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.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;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: #e74c3c;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: bold;
|
||||
min-width: 18px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.notification-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
width: 360px;
|
||||
max-height: 400px;
|
||||
max-height: 420px;
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
@@ -294,42 +285,39 @@ nav a.router-link-active {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.notification-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #2c3e50;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #e0e0e0;
|
||||
color: #2c3e50;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.notification-loading,
|
||||
.notification-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
@@ -338,28 +326,32 @@ nav a.router-link-active {
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: #f8f9fa;
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.notification-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background: #e8f4fd;
|
||||
border-left: 3px solid #3498db;
|
||||
background: var(--color-accent-light);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.notification-item.unread:hover {
|
||||
background: #d6ebfa;
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -369,21 +361,41 @@ nav a.router-link-active {
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
color: #555;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
color: #95a5a6;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@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 {
|
||||
width: 320px;
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
top: 60px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #2c3e50;
|
||||
background: #f5f5f5;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
#app {
|
||||
@@ -21,37 +21,37 @@ body {
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3498db;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2980b9;
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #27ae60;
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #229954;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
@@ -63,6 +63,7 @@ body {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
@@ -70,57 +71,61 @@ body {
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
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 */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-rejected {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.badge-canceled {
|
||||
background: #e2e3e5;
|
||||
color: #383d41;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
69
frontend/src/assets/theme.css
Normal file
69
frontend/src/assets/theme.css
Normal 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);
|
||||
}
|
||||
320
frontend/src/components/AppSidebar.vue
Normal file
320
frontend/src/components/AppSidebar.vue
Normal 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>
|
||||
@@ -126,7 +126,7 @@ onMounted(() => {
|
||||
.attachments-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
@@ -134,19 +134,19 @@ onMounted(() => {
|
||||
.error,
|
||||
.no-attachments {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.no-attachments {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.attachment-items {
|
||||
@@ -160,9 +160,9 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -189,7 +189,7 @@ onMounted(() => {
|
||||
|
||||
.attachment-name {
|
||||
font-size: 14px;
|
||||
color: #3b82f6;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
@@ -203,23 +203,23 @@ onMounted(() => {
|
||||
|
||||
.attachment-meta {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
padding: 6px 12px;
|
||||
background: white;
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-danger);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
background: color-mix(in srgb, var(--color-danger) 5%, transparent);
|
||||
}
|
||||
|
||||
.btn-delete:disabled {
|
||||
|
||||
@@ -437,7 +437,7 @@ onMounted(() => {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@ onMounted(() => {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 400;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -464,26 +464,28 @@ onMounted(() => {
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
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-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.form-input-error {
|
||||
border-color: #ef4444;
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
background-color: #f3f4f6;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -494,24 +496,24 @@ onMounted(() => {
|
||||
.form-error {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #ef4444;
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.api-error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
padding: 12px;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
color: var(--color-success);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -525,11 +527,11 @@ onMounted(() => {
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -538,33 +540,34 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
background: var(--color-text-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #4b5563;
|
||||
background: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
border: 1px solid var(--color-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
|
||||
94
frontend/src/components/CollapsibleSection.vue
Normal file
94
frontend/src/components/CollapsibleSection.vue
Normal 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>
|
||||
@@ -313,32 +313,32 @@ defineExpose({ refresh })
|
||||
|
||||
<style scoped>
|
||||
.space-calendar {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.admin-notice {
|
||||
background: #e3f2fd;
|
||||
background: color-mix(in srgb, var(--color-info) 15%, transparent);
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
color: #1976d2;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-info);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@@ -357,23 +357,23 @@ defineExpose({ refresh })
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
background: var(--color-surface);
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: #1f2937;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
margin-bottom: 20px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.time-comparison {
|
||||
@@ -382,26 +382,27 @@ defineExpose({ refresh })
|
||||
gap: 16px;
|
||||
margin: 20px 0;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.old-time,
|
||||
.new-time {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.old-time strong,
|
||||
.new-time strong {
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 24px;
|
||||
color: #9ca3af;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
@@ -412,40 +413,40 @@ defineExpose({ refresh })
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #93c5fd;
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
@@ -459,28 +460,28 @@ defineExpose({ refresh })
|
||||
}
|
||||
|
||||
:deep(.fc-button) {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
:deep(.fc-button:hover) {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
background: var(--color-accent-hover);
|
||||
border-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
:deep(.fc-button-active) {
|
||||
background: #1d4ed8 !important;
|
||||
border-color: #1d4ed8 !important;
|
||||
background: var(--color-accent-hover) !important;
|
||||
border-color: var(--color-accent-hover) !important;
|
||||
}
|
||||
|
||||
:deep(.fc-daygrid-day-number) {
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.fc-col-header-cell-cushion) {
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
43
frontend/src/composables/useSidebar.ts
Normal file
43
frontend/src/composables/useSidebar.ts
Normal 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
|
||||
}
|
||||
}
|
||||
52
frontend/src/composables/useTheme.ts
Normal file
52
frontend/src/composables/useTheme.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/theme.css'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -3,57 +3,59 @@
|
||||
<div class="page-header">
|
||||
<h2>Admin Dashboard - Space Management</h2>
|
||||
<button class="btn btn-primary" @click="openCreateModal">
|
||||
<Plus :size="16" />
|
||||
Create New Space
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Spaces List -->
|
||||
<div class="card">
|
||||
<h3>All Spaces</h3>
|
||||
<CollapsibleSection title="All Spaces" :icon="Building2">
|
||||
<div v-if="loadingSpaces" class="loading">Loading spaces...</div>
|
||||
<div v-else-if="spaces.length === 0" class="empty">
|
||||
No spaces created yet. Create one above!
|
||||
</div>
|
||||
<table v-else class="spaces-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Capacity</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="space in spaces" :key="space.id">
|
||||
<td>{{ space.name }}</td>
|
||||
<td>{{ space.type === 'sala' ? 'Sala' : 'Birou' }}</td>
|
||||
<td>{{ space.capacity }}</td>
|
||||
<td>
|
||||
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
|
||||
{{ space.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
@click="startEdit(space)"
|
||||
:disabled="loading"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
:class="['btn', 'btn-sm', space.is_active ? 'btn-warning' : 'btn-success']"
|
||||
@click="toggleStatus(space)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ space.is_active ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Capacity</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="space in spaces" :key="space.id">
|
||||
<td>{{ space.name }}</td>
|
||||
<td>{{ space.type === 'sala' ? 'Sala' : 'Birou' }}</td>
|
||||
<td>{{ space.capacity }}</td>
|
||||
<td>
|
||||
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
|
||||
{{ space.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
@click="startEdit(space)"
|
||||
:disabled="loading"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
:class="['btn', 'btn-sm', space.is_active ? 'btn-warning' : 'btn-success']"
|
||||
@click="toggleStatus(space)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ space.is_active ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Create/Edit Space Modal -->
|
||||
<div v-if="showModal" class="modal" @click.self="closeModal">
|
||||
@@ -179,6 +181,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { spacesApi, handleApiError } from '@/services/api'
|
||||
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
||||
import { Building2, Plus } from 'lucide-vue-next'
|
||||
import type { Space } from '@/types'
|
||||
|
||||
const spaces = ref<Space[]>([])
|
||||
@@ -305,17 +309,18 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.space-form {
|
||||
@@ -328,20 +333,20 @@ onMounted(() => {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.form-section-header h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@@ -364,7 +369,7 @@ onMounted(() => {
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -372,17 +377,19 @@ onMounted(() => {
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@@ -392,13 +399,16 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -407,39 +417,39 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #4b5563;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #10b981;
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
background: color-mix(in srgb, var(--color-success) 85%, black);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #f59e0b;
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: #d97706;
|
||||
background: color-mix(in srgb, var(--color-warning) 85%, black);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@@ -449,53 +459,58 @@ onMounted(() => {
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 12px;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
color: var(--color-success);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
color: var(--color-text-muted);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.spaces-table {
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.spaces-table th {
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.spaces-table td {
|
||||
.data-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.spaces-table tr:hover {
|
||||
background: #f9fafb;
|
||||
.data-table tr:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -507,13 +522,13 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -521,19 +536,6 @@ onMounted(() => {
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -548,18 +550,30 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-top: 0;
|
||||
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>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
<h2>Admin Dashboard - Pending Booking Requests</h2>
|
||||
|
||||
<!-- Filters Card -->
|
||||
<div class="card">
|
||||
<h3>Filters</h3>
|
||||
<CollapsibleSection title="Filters" :icon="Filter">
|
||||
<div class="filters">
|
||||
<div class="form-group">
|
||||
<label for="filter-space">Filter by Space</label>
|
||||
@@ -16,7 +15,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="card">
|
||||
@@ -32,65 +31,66 @@
|
||||
</div>
|
||||
|
||||
<!-- Bookings Table -->
|
||||
<div v-else class="card">
|
||||
<h3>Pending Requests ({{ bookings.length }})</h3>
|
||||
<table class="bookings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Space</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Title</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="booking in bookings" :key="booking.id">
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ booking.user?.full_name || 'Unknown' }}</div>
|
||||
<div class="user-email">{{ booking.user?.email || '-' }}</div>
|
||||
<div class="user-org" v-if="booking.user?.organization">
|
||||
{{ booking.user.organization }}
|
||||
<CollapsibleSection v-else :title="`Pending Requests (${bookings.length})`" :icon="ClipboardCheck">
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Space</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Title</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="booking in bookings" :key="booking.id">
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ booking.user?.full_name || 'Unknown' }}</div>
|
||||
<div class="user-email">{{ booking.user?.email || '-' }}</div>
|
||||
<div class="user-org" v-if="booking.user?.organization">
|
||||
{{ booking.user.organization }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="space-info">
|
||||
<div class="space-name">{{ booking.space?.name || 'Unknown Space' }}</div>
|
||||
<div class="space-type">{{ formatType(booking.space?.type || '') }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatDate(booking.start_datetime) }}</td>
|
||||
<td>{{ formatTime(booking.start_datetime, booking.end_datetime) }}</td>
|
||||
<td>{{ booking.title }}</td>
|
||||
<td>
|
||||
<div class="description" :title="booking.description || '-'">
|
||||
{{ truncateText(booking.description || '-', 40) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
@click="handleApprove(booking)"
|
||||
:disabled="processing === booking.id"
|
||||
>
|
||||
{{ processing === booking.id ? 'Processing...' : 'Approve' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
@click="showRejectModal(booking)"
|
||||
:disabled="processing === booking.id"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="space-info">
|
||||
<div class="space-name">{{ booking.space?.name || 'Unknown Space' }}</div>
|
||||
<div class="space-type">{{ formatType(booking.space?.type || '') }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatDate(booking.start_datetime) }}</td>
|
||||
<td>{{ formatTime(booking.start_datetime, booking.end_datetime) }}</td>
|
||||
<td>{{ booking.title }}</td>
|
||||
<td>
|
||||
<div class="description" :title="booking.description || '-'">
|
||||
{{ truncateText(booking.description || '-', 40) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
@click="handleApprove(booking)"
|
||||
:disabled="processing === booking.id"
|
||||
>
|
||||
{{ processing === booking.id ? 'Processing...' : 'Approve' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
@click="showRejectModal(booking)"
|
||||
:disabled="processing === booking.id"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Reject Modal -->
|
||||
<div v-if="rejectingBooking" class="modal" @click.self="closeRejectModal">
|
||||
@@ -134,7 +134,7 @@
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="success" class="card">
|
||||
<div class="success">{{ success }}</div>
|
||||
<div class="success-msg">{{ success }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -144,6 +144,8 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { adminBookingsApi, spacesApi, handleApiError } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@@ -279,17 +281,24 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-pending {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
h2 {
|
||||
margin-bottom: 24px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-top: 16px;
|
||||
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 {
|
||||
@@ -306,22 +315,25 @@ onMounted(() => {
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
@@ -332,53 +344,58 @@ onMounted(() => {
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
color: var(--color-text-muted);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.success {
|
||||
.success-msg {
|
||||
padding: 12px;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
color: var(--color-success);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.bookings-table {
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.bookings-table th {
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bookings-table td {
|
||||
.data-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
vertical-align: top;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.bookings-table tr:hover {
|
||||
background: #f9fafb;
|
||||
.data-table tr:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.user-info,
|
||||
@@ -391,14 +408,14 @@ onMounted(() => {
|
||||
.user-name,
|
||||
.space-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.user-email,
|
||||
.user-org,
|
||||
.space-type {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.description {
|
||||
@@ -416,11 +433,11 @@ onMounted(() => {
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -434,30 +451,30 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #10b981;
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
background: color-mix(in srgb, var(--color-success) 85%, black);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
background: color-mix(in srgb, var(--color-danger) 85%, black);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #4b5563;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.modal {
|
||||
@@ -474,22 +491,23 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.booking-summary {
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -497,10 +515,10 @@ onMounted(() => {
|
||||
.booking-summary p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.booking-summary strong {
|
||||
color: #1f2937;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
<h2>Booking Reports</h2>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div class="filters">
|
||||
<label>
|
||||
Start Date:
|
||||
<input type="date" v-model="startDate" />
|
||||
</label>
|
||||
<CollapsibleSection title="Date Range Filter" :icon="CalendarDays">
|
||||
<div class="filters">
|
||||
<label>
|
||||
Start Date:
|
||||
<input type="date" v-model="startDate" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
End Date:
|
||||
<input type="date" v-model="endDate" />
|
||||
</label>
|
||||
<label>
|
||||
End Date:
|
||||
<input type="date" v-model="endDate" />
|
||||
</label>
|
||||
|
||||
<button @click="loadReports" class="btn-primary">Refresh</button>
|
||||
<button @click="clearFilters" class="btn-secondary">Clear Filters</button>
|
||||
</div>
|
||||
<button @click="loadReports" class="btn btn-primary">Refresh</button>
|
||||
<button @click="clearFilters" class="btn btn-secondary">Clear Filters</button>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="loading">Loading reports...</div>
|
||||
@@ -53,63 +55,67 @@
|
||||
<div v-if="activeTab === 'usage' && !loading" class="report-content">
|
||||
<h3>Space Usage Report</h3>
|
||||
<canvas ref="usageChart"></canvas>
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Space</th>
|
||||
<th>Total</th>
|
||||
<th>Approved</th>
|
||||
<th>Pending</th>
|
||||
<th>Rejected</th>
|
||||
<th>Canceled</th>
|
||||
<th>Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in usageReport?.items" :key="item.space_id">
|
||||
<td>{{ item.space_name }}</td>
|
||||
<td>{{ item.total_bookings }}</td>
|
||||
<td class="status-approved">{{ item.approved_bookings }}</td>
|
||||
<td class="status-pending">{{ item.pending_bookings }}</td>
|
||||
<td class="status-rejected">{{ item.rejected_bookings }}</td>
|
||||
<td class="status-canceled">{{ item.canceled_bookings }}</td>
|
||||
<td>{{ item.total_hours.toFixed(1) }}h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>Total</strong></td>
|
||||
<td><strong>{{ usageReport?.total_bookings }}</strong></td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div class="table-responsive">
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Space</th>
|
||||
<th>Total</th>
|
||||
<th>Approved</th>
|
||||
<th>Pending</th>
|
||||
<th>Rejected</th>
|
||||
<th>Canceled</th>
|
||||
<th>Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in usageReport?.items" :key="item.space_id">
|
||||
<td>{{ item.space_name }}</td>
|
||||
<td>{{ item.total_bookings }}</td>
|
||||
<td class="status-approved">{{ item.approved_bookings }}</td>
|
||||
<td class="status-pending">{{ item.pending_bookings }}</td>
|
||||
<td class="status-rejected">{{ item.rejected_bookings }}</td>
|
||||
<td class="status-canceled">{{ item.canceled_bookings }}</td>
|
||||
<td>{{ item.total_hours.toFixed(1) }}h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>Total</strong></td>
|
||||
<td><strong>{{ usageReport?.total_bookings }}</strong></td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Users Report -->
|
||||
<div v-if="activeTab === 'users' && !loading" class="report-content">
|
||||
<h3>Top Users Report</h3>
|
||||
<canvas ref="usersChart"></canvas>
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Total Bookings</th>
|
||||
<th>Approved</th>
|
||||
<th>Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in topUsersReport?.items" :key="item.user_id">
|
||||
<td>{{ item.user_name }}</td>
|
||||
<td>{{ item.user_email }}</td>
|
||||
<td>{{ item.total_bookings }}</td>
|
||||
<td class="status-approved">{{ item.approved_bookings }}</td>
|
||||
<td>{{ item.total_hours.toFixed(1) }}h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-responsive">
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Total Bookings</th>
|
||||
<th>Approved</th>
|
||||
<th>Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in topUsersReport?.items" :key="item.user_id">
|
||||
<td>{{ item.user_name }}</td>
|
||||
<td>{{ item.user_email }}</td>
|
||||
<td>{{ item.total_bookings }}</td>
|
||||
<td class="status-approved">{{ item.approved_bookings }}</td>
|
||||
<td>{{ item.total_hours.toFixed(1) }}h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Approval Rate Report -->
|
||||
@@ -144,6 +150,8 @@
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { reportsApi } from '@/services/api'
|
||||
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'
|
||||
|
||||
const activeTab = ref('usage')
|
||||
@@ -295,25 +303,16 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-reports {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters label {
|
||||
@@ -321,58 +320,67 @@ h2 {
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filters input[type='date'] {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4caf50;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #45a049;
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #9e9e9e;
|
||||
color: white;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #757575;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
margin: 20px 0;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
@@ -382,29 +390,30 @@ h2 {
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
transition: all 0.3s;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #4caf50;
|
||||
border-bottom-color: #4caf50;
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.report-content {
|
||||
background: white;
|
||||
background: var(--color-surface);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.report-content h3 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
canvas {
|
||||
@@ -412,6 +421,10 @@ canvas {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.report-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -422,38 +435,38 @@ canvas {
|
||||
.report-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.report-table th {
|
||||
background: #f5f5f5;
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.report-table tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.report-table tfoot {
|
||||
font-weight: bold;
|
||||
background: #f5f5f5;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.status-approved {
|
||||
color: #4caf50;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: #ffa500;
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.status-rejected {
|
||||
color: #f44336;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.status-canceled {
|
||||
color: #9e9e9e;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.stats {
|
||||
@@ -465,8 +478,8 @@ canvas {
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
@@ -474,42 +487,54 @@ canvas {
|
||||
.stat-card h3 {
|
||||
font-size: 2em;
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-card.approved {
|
||||
background: #e8f5e9;
|
||||
border-color: #4caf50;
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.stat-card.approved h3 {
|
||||
color: #4caf50;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.stat-card.rejected {
|
||||
background: #ffebee;
|
||||
border-color: #f44336;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.stat-card.rejected h3 {
|
||||
color: #f44336;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.breakdown {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.breakdown p {
|
||||
margin: 8px 0;
|
||||
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>
|
||||
|
||||
@@ -19,55 +19,57 @@
|
||||
<input type="date" v-model="filters.start_date" placeholder="Data început" />
|
||||
<input type="date" v-model="filters.end_date" placeholder="Data sfârșit" />
|
||||
|
||||
<button @click="loadLogs">Filtrează</button>
|
||||
<button @click="resetFilters">Resetează</button>
|
||||
<button @click="loadLogs" class="btn btn-primary">Filtrează</button>
|
||||
<button @click="resetFilters" class="btn btn-secondary">Resetează</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<p v-if="loading">Se încarcă...</p>
|
||||
<p v-if="loading" class="loading-text">Se încarcă...</p>
|
||||
|
||||
<!-- Error state -->
|
||||
<p v-else-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<!-- Table -->
|
||||
<table v-else-if="logs.length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Utilizator</th>
|
||||
<th>Acțiune</th>
|
||||
<th>Tip Target</th>
|
||||
<th>ID Target</th>
|
||||
<th>Detalii</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in logs" :key="log.id">
|
||||
<td>{{ formatDate(log.created_at) }}</td>
|
||||
<td>
|
||||
<div>{{ log.user_name }}</div>
|
||||
<small>{{ log.user_email }}</small>
|
||||
</td>
|
||||
<td>{{ formatAction(log.action) }}</td>
|
||||
<td>{{ log.target_type }}</td>
|
||||
<td>{{ log.target_id }}</td>
|
||||
<td>
|
||||
<pre v-if="log.details && Object.keys(log.details).length > 0">{{
|
||||
formatDetails(log.details)
|
||||
}}</pre>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else-if="logs.length > 0" class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Utilizator</th>
|
||||
<th>Acțiune</th>
|
||||
<th>Tip Target</th>
|
||||
<th>ID Target</th>
|
||||
<th>Detalii</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in logs" :key="log.id">
|
||||
<td>{{ formatDate(log.created_at) }}</td>
|
||||
<td>
|
||||
<div>{{ log.user_name }}</div>
|
||||
<small>{{ log.user_email }}</small>
|
||||
</td>
|
||||
<td>{{ formatAction(log.action) }}</td>
|
||||
<td>{{ log.target_type }}</td>
|
||||
<td>{{ log.target_id }}</td>
|
||||
<td>
|
||||
<pre v-if="log.details && Object.keys(log.details).length > 0">{{
|
||||
formatDetails(log.details)
|
||||
}}</pre>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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 -->
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
@@ -158,14 +160,9 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-log {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.filters {
|
||||
@@ -179,81 +176,114 @@ h2 {
|
||||
.filters select,
|
||||
.filters input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.filters button {
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.filters button:hover {
|
||||
background-color: #45a049;
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.filters button:last-child {
|
||||
background-color: #9e9e9e;
|
||||
.btn-primary {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filters button:last-child:hover {
|
||||
background-color: #757575;
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
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 {
|
||||
color: #f44336;
|
||||
color: var(--color-danger);
|
||||
padding: 10px;
|
||||
background-color: #ffebee;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
table {
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: #f5f5f5;
|
||||
.data-table thead {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
th {
|
||||
.data-table th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
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;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
td small {
|
||||
color: #757575;
|
||||
.data-table td small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: #f9f9f9;
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
max-width: 300px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
@@ -264,25 +294,20 @@ pre {
|
||||
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 {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filters select,
|
||||
.filters input,
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,126 +11,72 @@
|
||||
<!-- Dashboard Content -->
|
||||
<div v-else class="dashboard-content">
|
||||
<!-- Quick Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-total">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<CollapsibleSection title="Quick Stats" :icon="BarChart3">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-total">
|
||||
<Calendar :size="28" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3>{{ stats.total }}</h3>
|
||||
<p>Total Bookings</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3>{{ stats.total }}</h3>
|
||||
<p>Total Bookings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-pending">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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 class="stat-card">
|
||||
<div class="stat-icon stat-icon-pending">
|
||||
<Clock :size="28" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3>{{ stats.pending }}</h3>
|
||||
<p>Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3>{{ stats.pending }}</h3>
|
||||
<p>Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon stat-icon-approved">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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 class="stat-card">
|
||||
<div class="stat-icon stat-icon-approved">
|
||||
<CheckCircle :size="28" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3>{{ stats.approved }}</h3>
|
||||
<p>Approved</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3>{{ stats.approved }}</h3>
|
||||
<p>Approved</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin: Pending Requests -->
|
||||
<div v-if="isAdmin" class="stat-card">
|
||||
<div class="stat-icon stat-icon-admin">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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 class="stat-content">
|
||||
<h3>{{ adminStats.pendingRequests }}</h3>
|
||||
<p>Pending Requests</p>
|
||||
<!-- Admin: Pending Requests -->
|
||||
<div v-if="isAdmin" class="stat-card">
|
||||
<div class="stat-icon stat-icon-admin">
|
||||
<Users :size="28" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<h3>{{ adminStats.pendingRequests }}</h3>
|
||||
<p>Pending Requests</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card quick-actions">
|
||||
<h3>Quick Actions</h3>
|
||||
<div class="actions-grid">
|
||||
<router-link to="/spaces" class="action-btn">
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Search :size="24" class="action-icon" />
|
||||
<span>Book a Space</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/my-bookings" class="action-btn">
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<ClipboardList :size="24" class="action-icon" />
|
||||
<span>My Bookings</span>
|
||||
</router-link>
|
||||
|
||||
<router-link v-if="isAdmin" to="/admin/bookings" class="action-btn">
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<ClipboardCheck :size="24" class="action-icon" />
|
||||
<span>Manage Bookings</span>
|
||||
</router-link>
|
||||
|
||||
<router-link v-if="isAdmin" to="/admin/spaces" class="action-btn">
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Building2 :size="24" class="action-icon" />
|
||||
<span>Manage Spaces</span>
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -138,21 +84,13 @@
|
||||
|
||||
<div class="content-grid">
|
||||
<!-- Upcoming Bookings -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Upcoming Bookings</h3>
|
||||
<CollapsibleSection title="Upcoming Bookings" :icon="CalendarDays">
|
||||
<template #header-actions>
|
||||
<router-link to="/my-bookings" class="link">View All</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="upcomingBookings.length === 0" class="empty-state-small">
|
||||
<svg class="icon-empty" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Calendar :size="48" class="icon-empty" />
|
||||
<p>No upcoming bookings</p>
|
||||
</div>
|
||||
|
||||
@@ -163,14 +101,7 @@
|
||||
class="booking-item"
|
||||
>
|
||||
<div class="booking-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Calendar :size="20" />
|
||||
</div>
|
||||
<div class="booking-info">
|
||||
<h4>{{ booking.title }}</h4>
|
||||
@@ -184,24 +115,16 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Available Spaces -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Available Spaces</h3>
|
||||
<CollapsibleSection title="Available Spaces" :icon="Building2">
|
||||
<template #header-actions>
|
||||
<router-link to="/spaces" class="link">View All</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="availableSpaces.length === 0" class="empty-state-small">
|
||||
<svg class="icon-empty" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Building2 :size="48" class="icon-empty" />
|
||||
<p>No available spaces</p>
|
||||
</div>
|
||||
|
||||
@@ -213,14 +136,7 @@
|
||||
class="space-item"
|
||||
>
|
||||
<div class="space-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Building2 :size="20" />
|
||||
</div>
|
||||
<div class="space-info">
|
||||
<h4>{{ space.name }}</h4>
|
||||
@@ -228,25 +144,17 @@
|
||||
{{ formatType(space.type) }} · Capacity: {{ space.capacity }}
|
||||
</p>
|
||||
</div>
|
||||
<svg class="icon-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<ChevronRight :size="20" class="icon-chevron" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
<!-- Admin: Recent Audit Logs -->
|
||||
<div v-if="isAdmin" class="card">
|
||||
<div class="card-header">
|
||||
<h3>Recent Activity</h3>
|
||||
<CollapsibleSection v-if="isAdmin" title="Recent Activity" :icon="ScrollText">
|
||||
<template #header-actions>
|
||||
<router-link to="/admin/audit-log" class="link">View All</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="recentAuditLogs.length === 0" class="empty-state-small">
|
||||
<p>No recent activity</p>
|
||||
@@ -255,14 +163,7 @@
|
||||
<div v-else class="audit-list">
|
||||
<div v-for="log in recentAuditLogs" :key="log.id" class="audit-item">
|
||||
<div class="audit-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<FileText :size="20" />
|
||||
</div>
|
||||
<div class="audit-info">
|
||||
<p class="audit-action">{{ log.action }}</p>
|
||||
@@ -271,7 +172,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -288,6 +189,22 @@ import {
|
||||
} from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@@ -402,15 +319,10 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard h2 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
@@ -421,14 +333,14 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border: 4px solid var(--color-border);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
@@ -444,21 +356,21 @@ onMounted(() => {
|
||||
.dashboard-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
@@ -467,53 +379,49 @@ onMounted(() => {
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.stat-icon-total {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.stat-icon-pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.stat-icon-approved {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.stat-icon-admin {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.stat-content h3 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-content p {
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.quick-actions h3 {
|
||||
margin-bottom: 20px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
@@ -527,23 +435,21 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
color: #374151;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #e5e7eb;
|
||||
background: var(--color-bg-tertiary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-btn .icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #3b82f6;
|
||||
.action-icon {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Content Grid */
|
||||
@@ -555,35 +461,23 @@ onMounted(() => {
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #3b82f6;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: #2563eb;
|
||||
color: var(--color-accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -591,18 +485,17 @@ onMounted(() => {
|
||||
.empty-state-small {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #9ca3af;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.icon-empty {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 12px;
|
||||
color: #d1d5db;
|
||||
color: var(--color-border);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-state-small p {
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -610,38 +503,33 @@ onMounted(() => {
|
||||
.bookings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.booking-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
padding: 14px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.booking-item:hover {
|
||||
background: #f3f4f6;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.booking-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.booking-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #3b82f6;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.booking-info {
|
||||
@@ -651,86 +539,53 @@ onMounted(() => {
|
||||
.booking-info h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.booking-space {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.booking-time {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Spaces List */
|
||||
.spaces-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.space-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.space-item:hover {
|
||||
background: #f3f4f6;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.space-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.space-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #3b82f6;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.space-info {
|
||||
@@ -740,51 +595,44 @@ onMounted(() => {
|
||||
.space-info h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.space-meta {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.icon-chevron {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #9ca3af;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Audit List */
|
||||
.audit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.audit-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.audit-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.audit-info {
|
||||
@@ -794,19 +642,19 @@ onMounted(() => {
|
||||
.audit-action {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.audit-user {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.audit-time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
|
||||
@@ -100,12 +100,12 @@ const handleLogin = async () => {
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@@ -117,11 +117,11 @@ h2 {
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.register-link a {
|
||||
color: #3498db;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -132,15 +132,15 @@ h2 {
|
||||
.demo-accounts {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e1e8ed;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.9rem;
|
||||
color: #7f8c8d;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.demo-accounts p {
|
||||
@@ -150,8 +150,9 @@ h2 {
|
||||
.error {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #fee;
|
||||
border-left: 3px solid #e74c3c;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-left: 3px solid var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -297,11 +297,9 @@ const formatStatus = (status: string): string => {
|
||||
const openEditModal = (booking: Booking) => {
|
||||
editingBooking.value = booking
|
||||
|
||||
// Extract date and time from ISO datetime
|
||||
const startLocal = isoToLocalDateTime(booking.start_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 [endDate, endTime] = endLocal.split('T')
|
||||
|
||||
@@ -330,7 +328,6 @@ const saveEdit = async () => {
|
||||
editError.value = ''
|
||||
|
||||
try {
|
||||
// Combine date and time, then convert to ISO
|
||||
const startDateTime = `${editForm.value.start_date}T${editForm.value.start_time}`
|
||||
const endDateTime = `${editForm.value.end_date}T${editForm.value.end_time}`
|
||||
|
||||
@@ -352,7 +349,6 @@ const saveEdit = async () => {
|
||||
}
|
||||
|
||||
const canCancel = (booking: Booking): boolean => {
|
||||
// Can only cancel pending or approved bookings
|
||||
return booking.status === 'pending' || booking.status === 'approved'
|
||||
}
|
||||
|
||||
@@ -365,9 +361,6 @@ const handleCancel = async (booking: Booking) => {
|
||||
error.value = ''
|
||||
|
||||
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.')
|
||||
await loadBookings()
|
||||
} catch (err) {
|
||||
@@ -383,17 +376,18 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.my-bookings {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
h2 {
|
||||
margin-bottom: 24px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 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 {
|
||||
@@ -408,34 +402,35 @@ onMounted(() => {
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: white;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filter-group select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
color: var(--color-text-muted);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -445,9 +440,9 @@ onMounted(() => {
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.bookings-table {
|
||||
@@ -459,20 +454,21 @@ onMounted(() => {
|
||||
.bookings-table th {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.bookings-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
vertical-align: middle;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.bookings-table tr:hover {
|
||||
background: #f9fafb;
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.space-info {
|
||||
@@ -483,12 +479,12 @@ onMounted(() => {
|
||||
|
||||
.space-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.space-type {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -500,23 +496,23 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-rejected {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.badge-canceled {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -525,17 +521,17 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.no-action {
|
||||
color: #9ca3af;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -546,21 +542,30 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -591,18 +596,19 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin: 0 0 20px 0;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -613,14 +619,14 @@ onMounted(() => {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -628,7 +634,7 @@ onMounted(() => {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 400;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -647,17 +653,24 @@ onMounted(() => {
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--color-accent);
|
||||
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 {
|
||||
@@ -666,9 +679,9 @@ onMounted(() => {
|
||||
|
||||
.error-message {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -679,15 +692,6 @@ onMounted(() => {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
/* Mobile Cards */
|
||||
.bookings-cards {
|
||||
display: none;
|
||||
@@ -705,10 +709,10 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.booking-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.booking-header {
|
||||
@@ -717,13 +721,13 @@ onMounted(() => {
|
||||
align-items: start;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.booking-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.booking-details {
|
||||
@@ -737,16 +741,17 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.booking-row .label {
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.booking-actions {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -145,12 +145,12 @@ const handleRegister = async () => {
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ h2 {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #7f8c8d;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
@@ -169,11 +169,11 @@ h2 {
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.login-link a {
|
||||
color: #3498db;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -184,18 +184,18 @@ h2 {
|
||||
.error {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #fee;
|
||||
border-left: 3px solid #e74c3c;
|
||||
border-radius: 4px;
|
||||
color: #c0392b;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-left: 3px solid var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.success {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #d4edda;
|
||||
border-left: 3px solid #28a745;
|
||||
border-radius: 4px;
|
||||
color: #155724;
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
border-left: 3px solid var(--color-success);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-success);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
<h2>Global Booking Settings</h2>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<div class="card">
|
||||
<h3>Booking Rules Configuration</h3>
|
||||
|
||||
<CollapsibleSection title="Booking Rules Configuration" :icon="Sliders">
|
||||
<div v-if="loadingSettings" class="loading">Loading settings...</div>
|
||||
|
||||
<form v-else @submit.prevent="handleSubmit" class="settings-form">
|
||||
@@ -108,25 +106,26 @@
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="success" class="success">{{ success }}</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Info Card -->
|
||||
<div class="card info-card">
|
||||
<h4>About These Settings</h4>
|
||||
<ul>
|
||||
<CollapsibleSection title="About These Settings" :icon="Info">
|
||||
<ul class="info-list">
|
||||
<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>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>
|
||||
</ul>
|
||||
<p class="note">These rules apply to all new booking requests.</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, handleApiError } from '@/services/api'
|
||||
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
||||
import { Sliders, Info } from 'lucide-vue-next'
|
||||
import type { Settings } from '@/types'
|
||||
|
||||
const loadingSettings = ref(true)
|
||||
@@ -163,7 +162,6 @@ const loadSettings = async () => {
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
// Validate all fields are positive
|
||||
if (
|
||||
formData.value.min_duration_minutes <= 0 ||
|
||||
formData.value.max_duration_minutes <= 0 ||
|
||||
@@ -176,19 +174,16 @@ const validateForm = (): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate min < max duration
|
||||
if (formData.value.min_duration_minutes >= formData.value.max_duration_minutes) {
|
||||
error.value = 'Minimum duration must be less than maximum duration'
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate working hours start < end
|
||||
if (formData.value.working_hours_start >= formData.value.working_hours_end) {
|
||||
error.value = 'Working hours start must be less than working hours end'
|
||||
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) {
|
||||
error.value = 'Working hours start must be between 0 and 23'
|
||||
return false
|
||||
@@ -206,7 +201,6 @@ const handleSubmit = async () => {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
// Client-side validation
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
@@ -239,21 +233,7 @@ onMounted(() => {
|
||||
|
||||
h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
@@ -276,18 +256,27 @@ h3, h4 {
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
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 {
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -300,19 +289,19 @@ h3, h4 {
|
||||
.btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #4caf50;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #45a049;
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -323,46 +312,48 @@ h3, h4 {
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background-color: #fee;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 4px;
|
||||
color: #c33;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-danger);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 0.75rem;
|
||||
background-color: #efe;
|
||||
border: 1px solid #cfc;
|
||||
border-radius: 4px;
|
||||
color: #3c3;
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-success);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.info-card ul {
|
||||
.info-list {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.info-card li {
|
||||
.info-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.info-list strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.collapsible-section + .collapsible-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -33,14 +33,7 @@
|
||||
<div class="space-meta">
|
||||
<span class="badge badge-type">{{ formatType(space.type) }}</span>
|
||||
<span class="badge badge-capacity">
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Users :size="16" />
|
||||
Capacity: {{ space.capacity }}
|
||||
</span>
|
||||
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
|
||||
@@ -53,14 +46,7 @@
|
||||
:disabled="!space.is_active"
|
||||
@click="handleReserve"
|
||||
>
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
<Plus :size="18" />
|
||||
{{ showBookingForm ? 'Cancel Reservation' : 'Reserve Space' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -99,6 +85,7 @@ import { useRoute } from 'vue-router'
|
||||
import { spacesApi, handleApiError } from '@/services/api'
|
||||
import SpaceCalendar from '@/components/SpaceCalendar.vue'
|
||||
import BookingForm from '@/components/BookingForm.vue'
|
||||
import { Users, Plus } from 'lucide-vue-next'
|
||||
import type { Space } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -132,7 +119,6 @@ const loadSpace = async () => {
|
||||
}
|
||||
|
||||
// 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 foundSpace = spaces.find((s) => s.id === spaceId)
|
||||
|
||||
@@ -170,11 +156,6 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.space-detail {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Breadcrumbs */
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
@@ -182,26 +163,26 @@ onMounted(() => {
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.breadcrumbs a {
|
||||
color: #3b82f6;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.breadcrumbs a:hover {
|
||||
color: #2563eb;
|
||||
color: var(--color-accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumbs .separator {
|
||||
color: #9ca3af;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.breadcrumbs .current {
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -212,14 +193,14 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border: 4px solid var(--color-border);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
@@ -239,21 +220,22 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
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;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.error-card h3 {
|
||||
color: #991b1b;
|
||||
color: var(--color-danger);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-card p {
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -281,7 +263,7 @@ onMounted(() => {
|
||||
.header-info h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -303,42 +285,37 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.badge-type {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.badge-capacity {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Reserve Button */
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -348,14 +325,14 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--color-accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-reserve {
|
||||
@@ -365,33 +342,29 @@ onMounted(() => {
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
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 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Description Card */
|
||||
.description-card p {
|
||||
color: #4b5563;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Booking Card */
|
||||
.booking-card h3 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Calendar Card */
|
||||
.calendar-subtitle {
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -411,14 +384,14 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
|
||||
@@ -42,14 +42,7 @@
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
<Building2 :size="80" class="empty-icon" />
|
||||
<h3>No Spaces Found</h3>
|
||||
<p>{{ selectedType || selectedStatus ? 'Try adjusting your filters' : 'No spaces are currently available' }}</p>
|
||||
</div>
|
||||
@@ -72,27 +65,13 @@
|
||||
<div class="space-card-body">
|
||||
<div class="space-info">
|
||||
<div class="info-item">
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Tag :size="18" class="info-icon" />
|
||||
<span class="label">Type:</span>
|
||||
<span class="value">{{ formatType(space.type) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<Users :size="18" class="info-icon" />
|
||||
<span class="label">Capacity:</span>
|
||||
<span class="value">{{ space.capacity }} {{ space.capacity === 1 ? 'person' : 'people' }}</span>
|
||||
</div>
|
||||
@@ -106,14 +85,7 @@
|
||||
<div class="space-card-footer">
|
||||
<button class="btn btn-secondary">
|
||||
View Details
|
||||
<svg class="icon-small" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<ChevronRight :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,6 +97,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { spacesApi, handleApiError } from '@/services/api'
|
||||
import { Building2, Tag, Users, ChevronRight } from 'lucide-vue-next'
|
||||
import type { Space } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -194,11 +167,6 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spaces {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.spaces-header {
|
||||
display: flex;
|
||||
@@ -210,12 +178,12 @@ onMounted(() => {
|
||||
.spaces-header h2 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -236,28 +204,27 @@ onMounted(() => {
|
||||
.filter-group label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
border-color: #9ca3af;
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
@@ -267,14 +234,14 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border: 4px solid var(--color-border);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
@@ -288,22 +255,23 @@ onMounted(() => {
|
||||
|
||||
/* Error State */
|
||||
.error-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
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;
|
||||
max-width: 500px;
|
||||
margin: 40px auto;
|
||||
}
|
||||
|
||||
.error-card h3 {
|
||||
color: #991b1b;
|
||||
color: var(--color-danger);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-card p {
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -314,21 +282,20 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
color: #d1d5db;
|
||||
color: var(--color-border);
|
||||
margin: 0 auto 24px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -341,19 +308,20 @@ onMounted(() => {
|
||||
|
||||
/* Space Card */
|
||||
.space-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
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;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.space-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.space-card-header {
|
||||
@@ -367,7 +335,7 @@ onMounted(() => {
|
||||
.space-card-header h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
color: var(--color-text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -375,20 +343,20 @@ onMounted(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.space-card-body {
|
||||
@@ -408,27 +376,25 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #9ca3af;
|
||||
.info-icon {
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.space-description {
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin-top: 12px;
|
||||
@@ -446,34 +412,29 @@ onMounted(() => {
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.icon-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
<h2>User Profile</h2>
|
||||
|
||||
<!-- Profile Information Card -->
|
||||
<div class="card">
|
||||
<h3>Profile Information</h3>
|
||||
<CollapsibleSection title="Profile Information" :icon="UserIcon">
|
||||
<div v-if="user" class="profile-info">
|
||||
<div class="info-item">
|
||||
<label>Email:</label>
|
||||
@@ -23,12 +22,10 @@
|
||||
<span>{{ user.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Timezone Preferences Card -->
|
||||
<div class="card">
|
||||
<h3>Timezone Preferences</h3>
|
||||
|
||||
<CollapsibleSection title="Timezone Preferences" :icon="Globe">
|
||||
<div v-if="loadingTimezones" class="loading">Loading timezones...</div>
|
||||
|
||||
<div v-else class="timezone-settings">
|
||||
@@ -56,18 +53,16 @@
|
||||
<div v-if="timezoneSuccess" class="success">{{ timezoneSuccess }}</div>
|
||||
<div v-if="timezoneError" class="error">{{ timezoneError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Google Calendar Integration Card -->
|
||||
<div class="card">
|
||||
<h3>Google Calendar Integration</h3>
|
||||
|
||||
<CollapsibleSection title="Google Calendar Integration" :icon="CalendarDays">
|
||||
<div v-if="loadingGoogleStatus" class="loading">Checking connection status...</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="googleStatus.connected" class="google-connected">
|
||||
<div class="status-indicator">
|
||||
<span class="status-icon">✓</span>
|
||||
<CheckCircle :size="20" class="status-icon-connected" />
|
||||
<span>Connected to Google Calendar</span>
|
||||
</div>
|
||||
|
||||
@@ -103,12 +98,11 @@
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="success" class="success">{{ success }}</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Info Card -->
|
||||
<div class="card info-card">
|
||||
<h4>About Calendar Integration</h4>
|
||||
<ul>
|
||||
<CollapsibleSection title="About Calendar Integration" :icon="Info">
|
||||
<ul class="info-list">
|
||||
<li>
|
||||
<strong>Automatic Sync:</strong> When your booking is approved, it's automatically added to
|
||||
your Google Calendar
|
||||
@@ -122,7 +116,7 @@
|
||||
above
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -131,6 +125,8 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { usersApi, googleCalendarApi, handleApiError } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@@ -184,7 +180,6 @@ const updateTimezone = async () => {
|
||||
try {
|
||||
await usersApi.updateTimezone(selectedTimezone.value)
|
||||
|
||||
// Update auth store
|
||||
if (authStore.user) {
|
||||
authStore.user.timezone = selectedTimezone.value
|
||||
}
|
||||
@@ -195,7 +190,6 @@ const updateTimezone = async () => {
|
||||
}, 5000)
|
||||
} catch (err) {
|
||||
timezoneError.value = handleApiError(err)
|
||||
// Revert selection on error
|
||||
if (user.value) {
|
||||
selectedTimezone.value = user.value.timezone
|
||||
}
|
||||
@@ -223,21 +217,17 @@ const connectGoogle = async () => {
|
||||
try {
|
||||
const response = await googleCalendarApi.connect()
|
||||
|
||||
// Open OAuth URL in popup window
|
||||
const popup = window.open(
|
||||
response.authorization_url,
|
||||
'Google Calendar Authorization',
|
||||
'width=600,height=600,toolbar=no,menubar=no,location=no'
|
||||
)
|
||||
|
||||
// Poll for connection status
|
||||
const pollInterval = setInterval(async () => {
|
||||
// Check if popup was closed
|
||||
if (popup && popup.closed) {
|
||||
clearInterval(pollInterval)
|
||||
connecting.value = false
|
||||
|
||||
// Check status one more time
|
||||
await checkGoogleStatus()
|
||||
|
||||
if (googleStatus.value.connected) {
|
||||
@@ -247,7 +237,6 @@ const connectGoogle = async () => {
|
||||
}, 3000)
|
||||
}
|
||||
} else {
|
||||
// Poll for connection status
|
||||
try {
|
||||
const status = await googleCalendarApi.status()
|
||||
if (status.connected) {
|
||||
@@ -256,7 +245,6 @@ const connectGoogle = async () => {
|
||||
googleStatus.value = status
|
||||
success.value = 'Google Calendar connected successfully!'
|
||||
|
||||
// Close popup
|
||||
if (popup) {
|
||||
popup.close()
|
||||
}
|
||||
@@ -271,7 +259,6 @@ const connectGoogle = async () => {
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// Stop polling after 5 minutes
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval)
|
||||
connecting.value = false
|
||||
@@ -328,22 +315,7 @@ onMounted(() => {
|
||||
|
||||
h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
@@ -360,17 +332,17 @@ h4 {
|
||||
.info-item label {
|
||||
font-weight: 600;
|
||||
min-width: 120px;
|
||||
color: #555;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.google-connected,
|
||||
@@ -386,29 +358,21 @@ h4 {
|
||||
gap: 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: #4caf50;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 0.9rem;
|
||||
.status-icon-connected {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.expiry-info {
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: #555;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -420,35 +384,35 @@ h4 {
|
||||
|
||||
.benefits-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #4285f4;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #357ae8;
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: #c82333;
|
||||
background: color-mix(in srgb, var(--color-danger) 85%, black);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -458,34 +422,32 @@ h4 {
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background-color: #fee;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 4px;
|
||||
color: #c33;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-danger);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 0.75rem;
|
||||
background-color: #efe;
|
||||
border: 1px solid #cfc;
|
||||
border-radius: 4px;
|
||||
color: #3c3;
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-success);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.info-card ul {
|
||||
.info-list {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.info-card li {
|
||||
.info-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.info-list strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.timezone-settings {
|
||||
@@ -502,16 +464,18 @@ h4 {
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.timezone-select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
max-width: 400px;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.timezone-select:disabled {
|
||||
@@ -520,9 +484,13 @@ h4 {
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.collapsible-section + .collapsible-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<div class="page-header">
|
||||
<h2>Admin Dashboard - User Management</h2>
|
||||
<button class="btn btn-primary" @click="openCreateModal">
|
||||
<UserPlus :size="16" />
|
||||
Create New User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card">
|
||||
<h3>Filters</h3>
|
||||
<CollapsibleSection title="Filters" :icon="Filter">
|
||||
<div class="filters">
|
||||
<div class="form-group">
|
||||
<label for="filter-role">Filter by Role</label>
|
||||
@@ -31,68 +31,69 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Users List -->
|
||||
<div class="card">
|
||||
<h3>All Users</h3>
|
||||
<CollapsibleSection title="All Users" :icon="UsersIcon">
|
||||
<div v-if="loadingUsers" class="loading">Loading users...</div>
|
||||
<div v-else-if="users.length === 0" class="empty">
|
||||
No users found. {{ filterRole || filterOrganization ? 'Try different filters.' : 'Create one above!' }}
|
||||
</div>
|
||||
<table v-else class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Full Name</th>
|
||||
<th>Role</th>
|
||||
<th>Organization</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.full_name }}</td>
|
||||
<td>
|
||||
<span :class="['badge', user.role === 'admin' ? 'badge-admin' : 'badge-user']">
|
||||
{{ user.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user.organization || '-' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', user.is_active ? 'badge-active' : 'badge-inactive']">
|
||||
{{ user.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
@click="startEdit(user)"
|
||||
:disabled="loading"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
:class="['btn', 'btn-sm', user.is_active ? 'btn-warning' : 'btn-success']"
|
||||
@click="toggleStatus(user)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ user.is_active ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
@click="showResetPassword(user)"
|
||||
:disabled="loading"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Full Name</th>
|
||||
<th>Role</th>
|
||||
<th>Organization</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.full_name }}</td>
|
||||
<td>
|
||||
<span :class="['badge', user.role === 'admin' ? 'badge-admin' : 'badge-user']">
|
||||
{{ user.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user.organization || '-' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', user.is_active ? 'badge-active' : 'badge-inactive']">
|
||||
{{ user.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
@click="startEdit(user)"
|
||||
:disabled="loading"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
:class="['btn', 'btn-sm', user.is_active ? 'btn-warning' : 'btn-success']"
|
||||
@click="toggleStatus(user)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ user.is_active ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
@click="showResetPassword(user)"
|
||||
:disabled="loading"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Create/Edit User Modal -->
|
||||
<div v-if="showFormModal" class="modal" @click.self="closeFormModal">
|
||||
@@ -200,6 +201,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
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'
|
||||
|
||||
const users = ref<User[]>([])
|
||||
@@ -367,11 +370,6 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.users {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -383,20 +381,7 @@ onMounted(() => {
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.filters {
|
||||
@@ -405,6 +390,12 @@ onMounted(() => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -413,28 +404,31 @@ onMounted(() => {
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: #f3f4f6;
|
||||
background: var(--color-bg-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -445,13 +439,16 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@@ -460,48 +457,48 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #4b5563;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #10b981;
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
background: color-mix(in srgb, var(--color-success) 85%, black);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #f59e0b;
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: #d97706;
|
||||
background: color-mix(in srgb, var(--color-warning) 85%, black);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
background: color-mix(in srgb, var(--color-danger) 85%, black);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@@ -511,53 +508,58 @@ onMounted(() => {
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 12px;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
color: var(--color-success);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
color: var(--color-text-muted);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
color: var(--color-text-primary);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
.data-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background: #f9fafb;
|
||||
.data-table tr:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -569,23 +571,23 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.badge-user {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -608,15 +610,31 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
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>
|
||||
|
||||
@@ -129,8 +129,8 @@ const resendVerification = async () => {
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border: 4px solid var(--color-border);
|
||||
border-top: 4px solid var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1.5rem;
|
||||
@@ -153,7 +153,7 @@ const resendVerification = async () => {
|
||||
.icon-success {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #28a745;
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
font-size: 3rem;
|
||||
line-height: 80px;
|
||||
@@ -164,7 +164,7 @@ const resendVerification = async () => {
|
||||
.icon-error {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #e74c3c;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 3rem;
|
||||
line-height: 80px;
|
||||
@@ -173,17 +173,17 @@ const resendVerification = async () => {
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #2c3e50;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #7f8c8d;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #c0392b;
|
||||
color: var(--color-danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ p {
|
||||
}
|
||||
|
||||
.login-link a {
|
||||
color: #3498db;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -203,13 +203,13 @@ p {
|
||||
.resend-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #e1e8ed;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.resend-section > p {
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.resend-form {
|
||||
@@ -221,22 +221,25 @@ p {
|
||||
.resend-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.resend-input:focus {
|
||||
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 {
|
||||
padding: 0.75rem;
|
||||
background: #d4edda;
|
||||
border-left: 3px solid #28a745;
|
||||
border-radius: 4px;
|
||||
color: #155724;
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
border-left: 3px solid var(--color-success);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-success);
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user