Files
space-booking/frontend/src/views/AdminPending.vue
Claude Agent 0bf3e6a7e2 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>
2026-02-11 21:27:05 +00:00

525 lines
13 KiB
Vue

<template>
<div class="admin-pending">
<h2>Admin Dashboard - Pending Booking Requests</h2>
<!-- Filters Card -->
<CollapsibleSection title="Filters" :icon="Filter">
<div class="filters">
<div class="form-group">
<label for="filter-space">Filter by Space</label>
<select id="filter-space" v-model="filterSpaceId" @change="loadPendingBookings">
<option value="">All Spaces</option>
<option v-for="space in spaces" :key="space.id" :value="space.id">
{{ space.name }}
</option>
</select>
</div>
</div>
</CollapsibleSection>
<!-- Loading State -->
<div v-if="loading" class="card">
<div class="loading">Loading pending requests...</div>
</div>
<!-- Empty State -->
<div v-else-if="bookings.length === 0" class="card">
<div class="empty">
No pending requests found.
{{ filterSpaceId ? 'Try different filters.' : 'All bookings have been processed.' }}
</div>
</div>
<!-- Bookings Table -->
<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>
</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">
<div class="modal-content">
<h3>Reject Booking Request</h3>
<div class="booking-summary">
<p><strong>User:</strong> {{ rejectingBooking.user?.full_name }}</p>
<p><strong>Space:</strong> {{ rejectingBooking.space?.name }}</p>
<p><strong>Title:</strong> {{ rejectingBooking.title }}</p>
<p>
<strong>Date:</strong> {{ formatDate(rejectingBooking.start_datetime) }} -
{{ formatTime(rejectingBooking.start_datetime, rejectingBooking.end_datetime) }}
</p>
</div>
<form @submit.prevent="handleReject">
<div class="form-group">
<label for="reject_reason">Rejection Reason (optional)</label>
<textarea
id="reject_reason"
v-model="rejectReason"
rows="4"
placeholder="Provide a reason for rejection..."
></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-danger" :disabled="processing !== null">
{{ processing !== null ? 'Rejecting...' : 'Confirm Rejection' }}
</button>
<button type="button" class="btn btn-secondary" @click="closeRejectModal">
Cancel
</button>
</div>
</form>
</div>
</div>
<!-- Error Message -->
<div v-if="error" class="card">
<div class="error">{{ error }}</div>
</div>
<!-- Success Message -->
<div v-if="success" class="card">
<div class="success-msg">{{ success }}</div>
</div>
</div>
</template>
<script setup lang="ts">
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()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const bookings = ref<Booking[]>([])
const spaces = ref<Space[]>([])
const loading = ref(false)
const error = ref('')
const success = ref('')
const processing = ref<number | null>(null)
const filterSpaceId = ref<string>('')
const rejectingBooking = ref<Booking | null>(null)
const rejectReason = ref('')
const loadPendingBookings = async () => {
loading.value = true
error.value = ''
try {
const filters: { space_id?: number } = {}
if (filterSpaceId.value) {
filters.space_id = Number(filterSpaceId.value)
}
bookings.value = await adminBookingsApi.getPending(filters)
} catch (err) {
error.value = handleApiError(err)
} finally {
loading.value = false
}
}
const loadSpaces = async () => {
try {
spaces.value = await spacesApi.list()
} catch (err) {
console.error('Failed to load spaces:', err)
}
}
const formatDate = (datetime: string): string => {
return formatDateUtil(datetime, userTimezone.value)
}
const formatTime = (start: string, end: string): string => {
const startTime = formatTimeUtil(start, userTimezone.value)
const endTime = formatTimeUtil(end, userTimezone.value)
return `${startTime} - ${endTime}`
}
const formatType = (type: string): string => {
const typeMap: Record<string, string> = {
sala: 'Sala',
birou: 'Birou'
}
return typeMap[type] || type
}
const truncateText = (text: string, maxLength: number): string => {
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
const handleApprove = async (booking: Booking) => {
if (!confirm('Are you sure you want to approve this booking?')) {
return
}
processing.value = booking.id
error.value = ''
success.value = ''
try {
await adminBookingsApi.approve(booking.id)
success.value = `Booking "${booking.title}" approved successfully!`
// Remove from list
bookings.value = bookings.value.filter((b) => b.id !== booking.id)
// Clear success message after 3 seconds
setTimeout(() => {
success.value = ''
}, 3000)
} catch (err) {
error.value = handleApiError(err)
} finally {
processing.value = null
}
}
const showRejectModal = (booking: Booking) => {
rejectingBooking.value = booking
rejectReason.value = ''
}
const closeRejectModal = () => {
rejectingBooking.value = null
rejectReason.value = ''
}
const handleReject = async () => {
if (!rejectingBooking.value) return
processing.value = rejectingBooking.value.id
error.value = ''
success.value = ''
try {
await adminBookingsApi.reject(
rejectingBooking.value.id,
rejectReason.value || undefined
)
success.value = `Booking "${rejectingBooking.value.title}" rejected successfully!`
// Remove from list
bookings.value = bookings.value.filter((b) => b.id !== rejectingBooking.value!.id)
closeRejectModal()
// Clear success message after 3 seconds
setTimeout(() => {
success.value = ''
}, 3000)
} catch (err) {
error.value = handleApiError(err)
} finally {
processing.value = null
}
}
onMounted(() => {
loadSpaces()
loadPendingBookings()
})
</script>
<style scoped>
h2 {
margin-bottom: 24px;
color: var(--color-text-primary);
}
.card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 24px;
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 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-weight: 500;
color: var(--color-text-primary);
font-size: 14px;
}
.form-group select,
.form-group textarea {
padding: 8px 12px;
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: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 16px;
}
.loading {
text-align: center;
color: var(--color-text-secondary);
padding: 24px;
}
.empty {
text-align: center;
color: var(--color-text-muted);
padding: 24px;
}
.error {
padding: 12px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-danger);
border-radius: var(--radius-sm);
}
.success-msg {
padding: 12px;
background: color-mix(in srgb, var(--color-success) 10%, transparent);
color: var(--color-success);
border-radius: var(--radius-sm);
}
.table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 12px;
background: var(--color-bg-secondary);
font-weight: 600;
color: var(--color-text-primary);
border-bottom: 2px solid var(--color-border);
white-space: nowrap;
}
.data-table td {
padding: 12px;
border-bottom: 1px solid var(--color-border);
vertical-align: top;
color: var(--color-text-primary);
}
.data-table tr:hover {
background: var(--color-surface-hover);
}
.user-info,
.space-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-name,
.space-name {
font-weight: 500;
color: var(--color-text-primary);
}
.user-email,
.user-org,
.space-type {
font-size: 12px;
color: var(--color-text-secondary);
}
.description {
max-width: 200px;
word-wrap: break-word;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
white-space: nowrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-success) 85%, black);
}
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-danger:hover:not(:disabled) {
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);
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 24px;
max-width: 600px;
width: 90%;
box-shadow: var(--shadow-lg);
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 16px;
color: var(--color-text-primary);
}
.booking-summary {
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
padding: 12px;
margin-bottom: 16px;
}
.booking-summary p {
margin: 8px 0;
font-size: 14px;
color: var(--color-text-secondary);
}
.booking-summary strong {
color: var(--color-text-primary);
}
</style>