- Dashboard redesign as command center with filters, quick actions, inline approve/reject - Reusable components: BookingRow, BookingFilters, ActionMenu, BookingPreviewModal, BookingEditModal - Calendar: drag & drop reschedule, eventClick preview modal, grid/list toggle - Mobile: segmented control bookings/calendar toggle, compact pills, responsive layout - Collapsible filters with active count badge - Smart menu positioning with Teleport - Calendar/list bidirectional data sync - Navigation: unified History page, removed AdminPending - Google Calendar OAuth integration - Dark mode contrast improvements, breadcrumb navigation - useLocalStorage composable for state persistence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
259 lines
5.8 KiB
Vue
259 lines
5.8 KiB
Vue
<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 class="active-actions">
|
||
<router-link :to="`/history`" class="action-btn action-btn-view" title="View bookings">
|
||
<Eye :size="16" />
|
||
</router-link>
|
||
<button
|
||
v-if="booking.status === 'pending'"
|
||
class="action-btn action-btn-edit"
|
||
title="Edit booking"
|
||
@click="$emit('refresh')"
|
||
>
|
||
<Pencil :size="16" />
|
||
</button>
|
||
<button
|
||
class="action-btn action-btn-cancel"
|
||
title="Cancel booking"
|
||
@click="$emit('cancel', booking)"
|
||
>
|
||
<X :size="16" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||
import { Zap, Eye, Pencil, X } 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[]
|
||
}>()
|
||
|
||
defineEmits<{
|
||
cancel: [booking: Booking]
|
||
refresh: []
|
||
}>()
|
||
|
||
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;
|
||
}
|
||
|
||
.active-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.action-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--color-border);
|
||
background: var(--color-surface);
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
transition: all var(--transition-fast);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.action-btn:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.action-btn-view:hover {
|
||
color: var(--color-info);
|
||
border-color: var(--color-info);
|
||
background: color-mix(in srgb, var(--color-info) 10%, transparent);
|
||
}
|
||
|
||
.action-btn-edit:hover {
|
||
color: var(--color-warning);
|
||
border-color: var(--color-warning);
|
||
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
||
}
|
||
|
||
.action-btn-cancel:hover {
|
||
color: var(--color-danger);
|
||
border-color: var(--color-danger);
|
||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||
}
|
||
|
||
/* Mobile responsive */
|
||
@media (max-width: 768px) {
|
||
.active-card-top {
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.active-remaining {
|
||
align-self: flex-start;
|
||
}
|
||
}
|
||
</style>
|