feat(dashboard): redesign with active bookings, calendar, and compact stats
Major Dashboard improvements focusing on active reservations and calendar view: Frontend changes: - Add ActiveBookings component showing in-progress bookings with progress bars - Add DashboardCalendar component with read-only calendar view of all user bookings - Refactor Dashboard layout: active bookings → stats grid → calendar → activity - Remove redundant Quick Actions and Available Spaces sections - Make Quick Stats compact (36px icons, 20px font) and clickable (router-link) - Add datetime utility functions (isBookingActive, getBookingProgress, formatRemainingTime) - Fix MyBookings to read status query parameter from URL - Auto-refresh active bookings every 60s with proper cleanup Backend changes: - Add GET /api/bookings/my/calendar endpoint with date range filtering - Fix Google Calendar sync in reschedule_booking and admin_update_booking - Add Google OAuth environment variables to .env.example Design: - Dark mode compatible with CSS variables throughout - Mobile responsive (768px breakpoint, 2-column stats grid) - CollapsibleSection pattern for all dashboard sections - Progress bars with accent colors for active bookings Performance: - Optimized API calls (calendar uses date range filtering) - Remove duplicate calendar data loading on mount - Computed property caching for stats and filtered bookings - Memory leak prevention (setInterval cleanup on unmount) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
189
frontend/src/components/ActiveBookings.vue
Normal file
189
frontend/src/components/ActiveBookings.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div v-if="activeBookings.length > 0" class="active-bookings">
|
||||
<div class="active-header">
|
||||
<Zap :size="20" class="active-icon" />
|
||||
<h3>Active Now</h3>
|
||||
</div>
|
||||
<div class="active-list">
|
||||
<div
|
||||
v-for="booking in activeBookings"
|
||||
:key="booking.id"
|
||||
class="active-card"
|
||||
>
|
||||
<div class="active-card-top">
|
||||
<div class="active-info">
|
||||
<h4>{{ booking.title }}</h4>
|
||||
<p class="active-space">{{ booking.space?.name || 'Space' }}</p>
|
||||
<p class="active-time">
|
||||
{{ formatTime(booking.start_datetime) }} – {{ formatTime(booking.end_datetime) }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="active-remaining">{{ getRemainingTime(booking.end_datetime) }}</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: getProgress(booking.start_datetime, booking.end_datetime) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Zap } from 'lucide-vue-next'
|
||||
import { isBookingActive, getBookingProgress, formatRemainingTime } from '@/utils/datetime'
|
||||
import { formatTime as formatTimeUtil } from '@/utils/datetime'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Booking } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
bookings: Booking[]
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||
|
||||
const now = ref(new Date())
|
||||
|
||||
const activeBookings = computed(() => {
|
||||
void now.value
|
||||
return props.bookings.filter(
|
||||
(b) => b.status === 'approved' && isBookingActive(b.start_datetime, b.end_datetime)
|
||||
)
|
||||
})
|
||||
|
||||
const formatTime = (datetime: string): string => {
|
||||
return formatTimeUtil(datetime, userTimezone.value)
|
||||
}
|
||||
|
||||
const getProgress = (start: string, end: string): number => {
|
||||
void now.value
|
||||
return getBookingProgress(start, end)
|
||||
}
|
||||
|
||||
const getRemainingTime = (end: string): string => {
|
||||
void now.value
|
||||
return formatRemainingTime(end)
|
||||
}
|
||||
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
refreshInterval = setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
refreshInterval = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.active-bookings {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.active-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--color-accent-light);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.active-icon {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.active-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.active-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: var(--color-border-light);
|
||||
}
|
||||
|
||||
.active-card {
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.active-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.active-info h4 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.active-space {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.active-time {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.active-remaining {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-hover));
|
||||
border-radius: 3px;
|
||||
transition: width 1s ease;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.active-card-top {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.active-remaining {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user