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>
525 lines
13 KiB
Vue
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>
|