fix: implement timezone-aware datetime display and editing
All datetime values are stored in UTC but were displaying raw UTC times to users, causing confusion (e.g., 10:00 Bucharest showing as 08:00). This implements proper timezone conversion throughout the app using each user's profile timezone setting. Changes: - Frontend: Replace local formatters with timezone-aware utilities - Backend: Add timezone conversion to PUT /bookings endpoint - FullCalendar: Configure to display events in user timezone - Fix edit modal to preserve times when editing bookings Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -467,9 +467,20 @@ def update_booking(
|
|||||||
detail="Can only edit pending bookings",
|
detail="Can only edit pending bookings",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Convert input times from user timezone to UTC
|
||||||
|
user_timezone = current_user.timezone or "UTC" # type: ignore[attr-defined]
|
||||||
|
|
||||||
# Prepare updated values (don't update model yet - validate first)
|
# Prepare updated values (don't update model yet - validate first)
|
||||||
updated_start = data.start_datetime if data.start_datetime is not None else booking.start_datetime # type: ignore[assignment]
|
# Convert datetimes to UTC if provided
|
||||||
updated_end = data.end_datetime if data.end_datetime is not None else booking.end_datetime # type: ignore[assignment]
|
if data.start_datetime is not None:
|
||||||
|
updated_start = convert_to_utc(data.start_datetime, user_timezone) # type: ignore[assignment]
|
||||||
|
else:
|
||||||
|
updated_start = booking.start_datetime # type: ignore[assignment]
|
||||||
|
|
||||||
|
if data.end_datetime is not None:
|
||||||
|
updated_end = convert_to_utc(data.end_datetime, user_timezone) # type: ignore[assignment]
|
||||||
|
else:
|
||||||
|
updated_end = booking.end_datetime # type: ignore[assignment]
|
||||||
|
|
||||||
# Re-validate booking rules BEFORE updating the model
|
# Re-validate booking rules BEFORE updating the model
|
||||||
user_id = int(current_user.id) # type: ignore[arg-type]
|
user_id = int(current_user.id) # type: ignore[arg-type]
|
||||||
@@ -494,9 +505,9 @@ def update_booking(
|
|||||||
if data.description is not None:
|
if data.description is not None:
|
||||||
booking.description = data.description # type: ignore[assignment]
|
booking.description = data.description # type: ignore[assignment]
|
||||||
if data.start_datetime is not None:
|
if data.start_datetime is not None:
|
||||||
booking.start_datetime = data.start_datetime # type: ignore[assignment]
|
booking.start_datetime = convert_to_utc(data.start_datetime, user_timezone) # type: ignore[assignment]
|
||||||
if data.end_datetime is not None:
|
if data.end_datetime is not None:
|
||||||
booking.end_datetime = data.end_datetime # type: ignore[assignment]
|
booking.end_datetime = convert_to_utc(data.end_datetime, user_timezone) # type: ignore[assignment]
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(booking)
|
db.refresh(booking)
|
||||||
|
|||||||
@@ -42,8 +42,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { attachmentsApi, handleApiError } from '@/services/api'
|
import { attachmentsApi, handleApiError } from '@/services/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
||||||
import type { Attachment } from '@/types'
|
import type { Attachment } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -58,6 +60,8 @@ interface Emits {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||||
const attachments = ref<Attachment[]>([])
|
const attachments = ref<Attachment[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -106,8 +110,7 @@ const formatFileSize = (bytes: number): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
const date = new Date(dateString)
|
return formatDateTimeUtil(dateString, userTimezone.value)
|
||||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -319,6 +319,7 @@
|
|||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { bookingsApi, bookingTemplatesApi, spacesApi, attachmentsApi, handleApiError } from '@/services/api'
|
import { bookingsApi, bookingTemplatesApi, spacesApi, attachmentsApi, handleApiError } from '@/services/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatDate as formatDateUtil } from '@/utils/datetime'
|
||||||
import type {
|
import type {
|
||||||
Space,
|
Space,
|
||||||
BookingCreate,
|
BookingCreate,
|
||||||
@@ -328,6 +329,7 @@ import type {
|
|||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
spaceId?: number
|
spaceId?: number
|
||||||
@@ -396,8 +398,6 @@ const weekDays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
|||||||
// Computed values
|
// Computed values
|
||||||
const activeSpaces = computed(() => spaces.value.filter((s) => s.is_active))
|
const activeSpaces = computed(() => spaces.value.filter((s) => s.is_active))
|
||||||
|
|
||||||
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
|
||||||
|
|
||||||
const selectedSpaceName = computed(() => {
|
const selectedSpaceName = computed(() => {
|
||||||
if (!props.spaceId) return ''
|
if (!props.spaceId) return ''
|
||||||
const space = spaces.value.find((s) => s.id === props.spaceId)
|
const space = spaces.value.find((s) => s.id === props.spaceId)
|
||||||
@@ -454,7 +454,7 @@ const firstOccurrences = computed(() => {
|
|||||||
while (current <= end && dates.length < 5) {
|
while (current <= end && dates.length < 5) {
|
||||||
const dayIndex = current.getDay() === 0 ? 6 : current.getDay() - 1
|
const dayIndex = current.getDay() === 0 ? 6 : current.getDay() - 1
|
||||||
if (selectedDays.value.includes(dayIndex)) {
|
if (selectedDays.value.includes(dayIndex)) {
|
||||||
dates.push(current.toLocaleDateString('en-GB'))
|
dates.push(formatDateUtil(current.toISOString(), userTimezone.value))
|
||||||
}
|
}
|
||||||
current.setDate(current.getDate() + 1)
|
current.setDate(current.getDate() + 1)
|
||||||
}
|
}
|
||||||
@@ -853,7 +853,7 @@ const showRecurringResult = (result: RecurringBookingResult) => {
|
|||||||
if (result.total_skipped > 0) {
|
if (result.total_skipped > 0) {
|
||||||
message += `\n\nSkipped ${result.total_skipped} dates due to conflicts:`
|
message += `\n\nSkipped ${result.total_skipped} dates due to conflicts:`
|
||||||
result.skipped_dates.slice(0, 5).forEach((skip) => {
|
result.skipped_dates.slice(0, 5).forEach((skip) => {
|
||||||
const formattedDate = new Date(skip.date).toLocaleDateString('en-GB')
|
const formattedDate = formatDateUtil(skip.date, userTimezone.value)
|
||||||
message += `\n- ${formattedDate}: ${skip.reason}`
|
message += `\n- ${formattedDate}: ${skip.reason}`
|
||||||
})
|
})
|
||||||
if (result.total_skipped > 5) {
|
if (result.total_skipped > 5) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<div v-if="loading && !confirmModal.show" class="loading">Loading calendar...</div>
|
<div v-if="loading && !confirmModal.show" class="loading">Loading calendar...</div>
|
||||||
<FullCalendar v-if="!loading" :options="calendarOptions" />
|
<FullCalendar v-show="!loading" :options="calendarOptions" />
|
||||||
|
|
||||||
<!-- Confirmation Modal -->
|
<!-- Confirmation Modal -->
|
||||||
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
|
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import FullCalendar from '@fullcalendar/vue3'
|
import FullCalendar from '@fullcalendar/vue3'
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||||
@@ -47,6 +47,7 @@ import interactionPlugin from '@fullcalendar/interaction'
|
|||||||
import type { CalendarOptions, EventInput, DatesSetArg, EventDropArg, EventResizeDoneArg } from '@fullcalendar/core'
|
import type { CalendarOptions, EventInput, DatesSetArg, EventDropArg, EventResizeDoneArg } from '@fullcalendar/core'
|
||||||
import { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
|
import { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
||||||
import type { Booking } from '@/types'
|
import type { Booking } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -56,8 +57,10 @@ interface Props {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||||
const bookings = ref<Booking[]>([])
|
const bookings = ref<Booking[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(true)
|
||||||
|
const initialLoad = ref(true)
|
||||||
const modalLoading = ref(false)
|
const modalLoading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
@@ -206,18 +209,17 @@ const cancelReschedule = () => {
|
|||||||
// Format datetime for display
|
// Format datetime for display
|
||||||
const formatDateTime = (date: Date | null) => {
|
const formatDateTime = (date: Date | null) => {
|
||||||
if (!date) return ''
|
if (!date) return ''
|
||||||
return date.toLocaleString('ro-RO', {
|
return formatDateTimeUtil(date.toISOString(), userTimezone.value)
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track current date range for refresh
|
||||||
|
let currentStart: Date | null = null
|
||||||
|
let currentEnd: Date | null = null
|
||||||
|
|
||||||
// Load bookings for a date range
|
// Load bookings for a date range
|
||||||
const loadBookings = async (start: Date, end: Date) => {
|
const loadBookings = async (start: Date, end: Date) => {
|
||||||
loading.value = true
|
currentStart = start
|
||||||
|
currentEnd = end
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -227,7 +229,10 @@ const loadBookings = async (start: Date, end: Date) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = handleApiError(err)
|
error.value = handleApiError(err)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (initialLoad.value) {
|
||||||
|
loading.value = false
|
||||||
|
initialLoad.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +250,7 @@ const calendarOptions = computed<CalendarOptions>(() => ({
|
|||||||
center: 'title',
|
center: 'title',
|
||||||
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
||||||
},
|
},
|
||||||
|
timeZone: userTimezone.value,
|
||||||
events: events.value,
|
events: events.value,
|
||||||
datesSet: handleDatesSet,
|
datesSet: handleDatesSet,
|
||||||
editable: isEditable.value, // Enable drag/resize for admins
|
editable: isEditable.value, // Enable drag/resize for admins
|
||||||
@@ -283,13 +289,19 @@ const calendarOptions = computed<CalendarOptions>(() => ({
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Load initial bookings on mount
|
// Public refresh method for parent components
|
||||||
onMounted(() => {
|
const refresh = () => {
|
||||||
const now = new Date()
|
if (currentStart && currentEnd) {
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
loadBookings(currentStart, currentEnd)
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
} else {
|
||||||
loadBookings(startOfMonth, endOfMonth)
|
const now = new Date()
|
||||||
})
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||||
|
loadBookings(startOfMonth, endOfMonth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -113,5 +113,5 @@ export const isoToLocalDateTime = (isoDateTime: string, timezone: string = 'UTC'
|
|||||||
const minute = date.toLocaleString('en-US', { timeZone: timezone, minute: '2-digit' })
|
const minute = date.toLocaleString('en-US', { timeZone: timezone, minute: '2-digit' })
|
||||||
|
|
||||||
// Format as YYYY-MM-DDTHH:mm for datetime-local input
|
// Format as YYYY-MM-DDTHH:mm for datetime-local input
|
||||||
return `${year}-${month}-${day}T${hour.padStart(2, '0')}:${minute}`
|
return `${year}-${month}-${day}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,10 +140,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { adminBookingsApi, spacesApi, handleApiError } from '@/services/api'
|
import { adminBookingsApi, spacesApi, handleApiError } from '@/services/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatDate as formatDateUtil, formatTime as formatTimeUtil } from '@/utils/datetime'
|
||||||
import type { Booking, Space } from '@/types'
|
import type { Booking, Space } from '@/types'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||||
const bookings = ref<Booking[]>([])
|
const bookings = ref<Booking[]>([])
|
||||||
const spaces = ref<Space[]>([])
|
const spaces = ref<Space[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -179,24 +183,13 @@ const loadSpaces = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (datetime: string): string => {
|
const formatDate = (datetime: string): string => {
|
||||||
const date = new Date(datetime)
|
return formatDateUtil(datetime, userTimezone.value)
|
||||||
return date.toLocaleDateString('en-GB', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (start: string, end: string): string => {
|
const formatTime = (start: string, end: string): string => {
|
||||||
const startDate = new Date(start)
|
const startTime = formatTimeUtil(start, userTimezone.value)
|
||||||
const endDate = new Date(end)
|
const endTime = formatTimeUtil(end, userTimezone.value)
|
||||||
const formatTimeOnly = (date: Date) =>
|
return `${startTime} - ${endTime}`
|
||||||
date.toLocaleTimeString('en-GB', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
})
|
|
||||||
return `${formatTimeOnly(startDate)} - ${formatTimeOnly(endDate)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatType = (type: string): string => {
|
const formatType = (type: string): string => {
|
||||||
|
|||||||
@@ -73,10 +73,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { auditLogApi } from '@/services/api'
|
import { auditLogApi } from '@/services/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
||||||
import type { AuditLog } from '@/types'
|
import type { AuditLog } from '@/types'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||||
const logs = ref<AuditLog[]>([])
|
const logs = ref<AuditLog[]>([])
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const limit = 50
|
const limit = 50
|
||||||
@@ -127,13 +131,7 @@ const nextPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleString('ro-RO', {
|
return formatDateTimeUtil(dateStr, userTimezone.value)
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAction = (action: string) => {
|
const formatAction = (action: string) => {
|
||||||
|
|||||||
@@ -1,22 +1,831 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<h2>Dashboard</h2>
|
<h2>Dashboard</h2>
|
||||||
<div class="card">
|
|
||||||
<p>Welcome to Space Booking System!</p>
|
<!-- Loading State -->
|
||||||
<p>Use the navigation to explore available spaces and manage your bookings.</p>
|
<div v-if="loading" class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading dashboard...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<span>Manage Spaces</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-grid">
|
||||||
|
<!-- Upcoming Bookings -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Upcoming Bookings</h3>
|
||||||
|
<router-link to="/my-bookings" class="link">View All</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<p>No upcoming bookings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="bookings-list">
|
||||||
|
<div
|
||||||
|
v-for="booking in upcomingBookings.slice(0, 5)"
|
||||||
|
:key="booking.id"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div class="booking-info">
|
||||||
|
<h4>{{ booking.title }}</h4>
|
||||||
|
<p class="booking-space">{{ booking.space?.name || 'Space' }}</p>
|
||||||
|
<p class="booking-time">
|
||||||
|
{{ formatDateTime(booking.start_datetime) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span :class="['badge', `badge-${booking.status}`]">
|
||||||
|
{{ formatStatus(booking.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available Spaces -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Available Spaces</h3>
|
||||||
|
<router-link to="/spaces" class="link">View All</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<p>No available spaces</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="spaces-list">
|
||||||
|
<router-link
|
||||||
|
v-for="space in availableSpaces.slice(0, 5)"
|
||||||
|
:key="space.id"
|
||||||
|
:to="`/spaces/${space.id}`"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div class="space-info">
|
||||||
|
<h4>{{ space.name }}</h4>
|
||||||
|
<p class="space-meta">
|
||||||
|
{{ 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>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin: Recent Audit Logs -->
|
||||||
|
<div v-if="isAdmin" class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Recent Activity</h3>
|
||||||
|
<router-link to="/admin/audit-log" class="link">View All</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="recentAuditLogs.length === 0" class="empty-state-small">
|
||||||
|
<p>No recent activity</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="audit-info">
|
||||||
|
<p class="audit-action">{{ log.action }}</p>
|
||||||
|
<p class="audit-user">{{ log.user_name }} ({{ log.user_email }})</p>
|
||||||
|
<p class="audit-time">{{ formatDateTime(log.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import {
|
||||||
|
spacesApi,
|
||||||
|
bookingsApi,
|
||||||
|
adminBookingsApi,
|
||||||
|
auditLogApi,
|
||||||
|
usersApi,
|
||||||
|
handleApiError
|
||||||
|
} from '@/services/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
||||||
|
import type { Space, Booking, AuditLog, User } from '@/types'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||||
|
const loading = ref(true)
|
||||||
|
const currentUser = ref<User | null>(null)
|
||||||
|
const myBookings = ref<Booking[]>([])
|
||||||
|
const spaces = ref<Space[]>([])
|
||||||
|
const pendingRequests = ref<Booking[]>([])
|
||||||
|
const auditLogs = ref<AuditLog[]>([])
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
const isAdmin = computed(() => currentUser.value?.role === 'admin')
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const stats = computed(() => {
|
||||||
|
return {
|
||||||
|
total: myBookings.value.length,
|
||||||
|
pending: myBookings.value.filter((b) => b.status === 'pending').length,
|
||||||
|
approved: myBookings.value.filter((b) => b.status === 'approved').length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Admin stats
|
||||||
|
const adminStats = computed(() => {
|
||||||
|
return {
|
||||||
|
pendingRequests: pendingRequests.value.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get upcoming bookings (approved or pending, sorted by start time)
|
||||||
|
const upcomingBookings = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return myBookings.value
|
||||||
|
.filter((b) => {
|
||||||
|
const startDate = new Date(b.start_datetime)
|
||||||
|
return startDate >= now && (b.status === 'approved' || b.status === 'pending')
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
return new Date(a.start_datetime).getTime() - new Date(b.start_datetime).getTime()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get available (active) spaces
|
||||||
|
const availableSpaces = computed(() => {
|
||||||
|
return spaces.value.filter((s) => s.is_active)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recent audit logs (last 5)
|
||||||
|
const recentAuditLogs = computed(() => {
|
||||||
|
return auditLogs.value.slice(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Format space type
|
||||||
|
const formatType = (type: string): string => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
desk: 'Desk',
|
||||||
|
meeting_room: 'Meeting Room',
|
||||||
|
conference_room: 'Conference Room'
|
||||||
|
}
|
||||||
|
return typeMap[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format booking status
|
||||||
|
const formatStatus = (status: string): string => {
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
approved: 'Approved',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
canceled: 'Canceled'
|
||||||
|
}
|
||||||
|
return statusMap[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date and time
|
||||||
|
const formatDateTime = (dateString: string): string => {
|
||||||
|
return formatDateTimeUtil(dateString, userTimezone.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load dashboard data
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load current user
|
||||||
|
currentUser.value = await usersApi.me()
|
||||||
|
|
||||||
|
// Load user's bookings
|
||||||
|
myBookings.value = await bookingsApi.getMy()
|
||||||
|
|
||||||
|
// Load available spaces
|
||||||
|
spaces.value = await spacesApi.list()
|
||||||
|
|
||||||
|
// Load admin data if user is admin
|
||||||
|
if (currentUser.value.role === 'admin') {
|
||||||
|
// Load pending requests
|
||||||
|
pendingRequests.value = await adminBookingsApi.getPending()
|
||||||
|
|
||||||
|
// Load recent audit logs
|
||||||
|
auditLogs.value = await auditLogApi.getAll({ limit: 5 })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading dashboard:', handleApiError(err))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDashboard()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard h2 {
|
.dashboard {
|
||||||
margin-bottom: 1.5rem;
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card p {
|
.dashboard h2 {
|
||||||
margin-bottom: 0.5rem;
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid #e5e7eb;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Content */
|
||||||
|
.dashboard-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 12px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-pending {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-approved {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-admin {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content h3 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick Actions */
|
||||||
|
.quick-actions h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #374151;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn .icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Grid */
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state-small {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-empty {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-small p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookings List */
|
||||||
|
.bookings-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-item:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-info h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-space {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spaces List */
|
||||||
|
.spaces-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-item:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-info h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-chevron {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audit List */
|
||||||
|
.audit-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-action {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-user {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -191,7 +191,7 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { bookingsApi, handleApiError } from '@/services/api'
|
import { bookingsApi, handleApiError } from '@/services/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { formatDate as formatDateTZ, formatTime as formatTimeTZ } from '@/utils/datetime'
|
import { formatDate as formatDateTZ, formatTime as formatTimeTZ, isoToLocalDateTime, localDateTimeToISO } from '@/utils/datetime'
|
||||||
import type { Booking } from '@/types'
|
import type { Booking } from '@/types'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -237,18 +237,6 @@ const formatTime = (start: string, end: string): string => {
|
|||||||
return `${startTime} - ${endTime}`
|
return `${startTime} - ${endTime}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTimeOld = (start: string, end: string): string => {
|
|
||||||
const startDate = new Date(start)
|
|
||||||
const endDate = new Date(end)
|
|
||||||
const formatTimeOnly = (date: Date) =>
|
|
||||||
date.toLocaleTimeString('en-GB', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
})
|
|
||||||
return `${formatTimeOnly(startDate)} - ${formatTimeOnly(endDate)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatType = (type: string): string => {
|
const formatType = (type: string): string => {
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
sala: 'Sala',
|
sala: 'Sala',
|
||||||
@@ -263,15 +251,12 @@ const formatStatus = (status: string): string => {
|
|||||||
|
|
||||||
const openEditModal = (booking: Booking) => {
|
const openEditModal = (booking: Booking) => {
|
||||||
editingBooking.value = booking
|
editingBooking.value = booking
|
||||||
// Convert ISO datetime to datetime-local format (YYYY-MM-DDTHH:MM)
|
// Convert ISO datetime to datetime-local format in user's timezone
|
||||||
const start = new Date(booking.start_datetime)
|
|
||||||
const end = new Date(booking.end_datetime)
|
|
||||||
|
|
||||||
editForm.value = {
|
editForm.value = {
|
||||||
title: booking.title,
|
title: booking.title,
|
||||||
description: booking.description || '',
|
description: booking.description || '',
|
||||||
start_datetime: start.toISOString().slice(0, 16),
|
start_datetime: isoToLocalDateTime(booking.start_datetime, userTimezone.value),
|
||||||
end_datetime: end.toISOString().slice(0, 16)
|
end_datetime: isoToLocalDateTime(booking.end_datetime, userTimezone.value)
|
||||||
}
|
}
|
||||||
editError.value = ''
|
editError.value = ''
|
||||||
showEditModal.value = true
|
showEditModal.value = true
|
||||||
@@ -290,12 +275,12 @@ const saveEdit = async () => {
|
|||||||
editError.value = ''
|
editError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert datetime-local format back to ISO
|
// Convert datetime-local format to ISO without Z (backend will handle timezone conversion)
|
||||||
const updateData = {
|
const updateData = {
|
||||||
title: editForm.value.title,
|
title: editForm.value.title,
|
||||||
description: editForm.value.description,
|
description: editForm.value.description,
|
||||||
start_datetime: new Date(editForm.value.start_datetime).toISOString(),
|
start_datetime: localDateTimeToISO(editForm.value.start_datetime),
|
||||||
end_datetime: new Date(editForm.value.end_datetime).toISOString()
|
end_datetime: localDateTimeToISO(editForm.value.end_datetime)
|
||||||
}
|
}
|
||||||
|
|
||||||
await bookingsApi.update(editingBooking.value.id, updateData)
|
await bookingsApi.update(editingBooking.value.id, updateData)
|
||||||
|
|||||||
@@ -127,12 +127,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { usersApi, googleCalendarApi, handleApiError } from '@/services/api'
|
import { usersApi, googleCalendarApi, handleApiError } from '@/services/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||||
|
|
||||||
const user = ref<User | null>(null)
|
const user = ref<User | null>(null)
|
||||||
const loadingGoogleStatus = ref(true)
|
const loadingGoogleStatus = ref(true)
|
||||||
@@ -308,8 +310,7 @@ const disconnectGoogle = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
const date = new Date(dateString)
|
return formatDateTimeUtil(dateString, userTimezone.value)
|
||||||
return date.toLocaleString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user