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

Implemented comprehensive UI overhaul with three-layer architecture:

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

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

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

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

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

View File

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

View File

@@ -15,6 +15,7 @@
"@fullcalendar/vue3": "^6.1.0",
"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",

View File

@@ -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"

View File

@@ -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">&times;</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>

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -126,7 +126,7 @@ onMounted(() => {
.attachments-title {
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 {

View File

@@ -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 {

View File

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

View File

@@ -313,32 +313,32 @@ defineExpose({ refresh })
<style scoped>
.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;
}

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>