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:
Claude Agent
2026-02-11 13:43:31 +00:00
parent df4031d99c
commit b93b8d2e71
10 changed files with 899 additions and 87 deletions

View File

@@ -467,9 +467,20 @@ def update_booking(
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)
updated_start = data.start_datetime if data.start_datetime is not None else booking.start_datetime # type: ignore[assignment]
updated_end = data.end_datetime if data.end_datetime is not None else booking.end_datetime # type: ignore[assignment]
# Convert datetimes to UTC if provided
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
user_id = int(current_user.id) # type: ignore[arg-type]
@@ -494,9 +505,9 @@ def update_booking(
if data.description is not None:
booking.description = data.description # type: ignore[assignment]
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:
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.refresh(booking)

View File

@@ -42,8 +42,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { attachmentsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
import type { Attachment } from '@/types'
interface Props {
@@ -58,6 +60,8 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const attachments = ref<Attachment[]>([])
const loading = ref(true)
const error = ref('')
@@ -106,8 +110,7 @@ const formatFileSize = (bytes: number): string => {
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return formatDateTimeUtil(dateString, userTimezone.value)
}
onMounted(() => {

View File

@@ -319,6 +319,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { bookingsApi, bookingTemplatesApi, spacesApi, attachmentsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDate as formatDateUtil } from '@/utils/datetime'
import type {
Space,
BookingCreate,
@@ -328,6 +329,7 @@ import type {
} from '@/types'
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
interface Props {
spaceId?: number
@@ -396,8 +398,6 @@ const weekDays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
// Computed values
const activeSpaces = computed(() => spaces.value.filter((s) => s.is_active))
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const selectedSpaceName = computed(() => {
if (!props.spaceId) return ''
const space = spaces.value.find((s) => s.id === props.spaceId)
@@ -454,7 +454,7 @@ const firstOccurrences = computed(() => {
while (current <= end && dates.length < 5) {
const dayIndex = current.getDay() === 0 ? 6 : current.getDay() - 1
if (selectedDays.value.includes(dayIndex)) {
dates.push(current.toLocaleDateString('en-GB'))
dates.push(formatDateUtil(current.toISOString(), userTimezone.value))
}
current.setDate(current.getDate() + 1)
}
@@ -853,7 +853,7 @@ const showRecurringResult = (result: RecurringBookingResult) => {
if (result.total_skipped > 0) {
message += `\n\nSkipped ${result.total_skipped} dates due to conflicts:`
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}`
})
if (result.total_skipped > 5) {

View File

@@ -5,7 +5,7 @@
</div>
<div v-if="error" class="error">{{ error }}</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 -->
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
@@ -39,7 +39,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
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 { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
import type { Booking } from '@/types'
interface Props {
@@ -56,8 +57,10 @@ interface Props {
const props = defineProps<Props>()
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const bookings = ref<Booking[]>([])
const loading = ref(false)
const loading = ref(true)
const initialLoad = ref(true)
const modalLoading = ref(false)
const error = ref('')
@@ -206,18 +209,17 @@ const cancelReschedule = () => {
// Format datetime for display
const formatDateTime = (date: Date | null) => {
if (!date) return ''
return date.toLocaleString('ro-RO', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
return formatDateTimeUtil(date.toISOString(), userTimezone.value)
}
// Track current date range for refresh
let currentStart: Date | null = null
let currentEnd: Date | null = null
// Load bookings for a date range
const loadBookings = async (start: Date, end: Date) => {
loading.value = true
currentStart = start
currentEnd = end
error.value = ''
try {
@@ -227,7 +229,10 @@ const loadBookings = async (start: Date, end: Date) => {
} catch (err) {
error.value = handleApiError(err)
} finally {
loading.value = false
if (initialLoad.value) {
loading.value = false
initialLoad.value = false
}
}
}
@@ -245,6 +250,7 @@ const calendarOptions = computed<CalendarOptions>(() => ({
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
timeZone: userTimezone.value,
events: events.value,
datesSet: handleDatesSet,
editable: isEditable.value, // Enable drag/resize for admins
@@ -283,13 +289,19 @@ const calendarOptions = computed<CalendarOptions>(() => ({
}
}))
// Load initial bookings on mount
onMounted(() => {
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)
})
// Public refresh method for parent components
const refresh = () => {
if (currentStart && currentEnd) {
loadBookings(currentStart, currentEnd)
} else {
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>
<style scoped>

View File

@@ -113,5 +113,5 @@ export const isoToLocalDateTime = (isoDateTime: string, timezone: string = 'UTC'
const minute = date.toLocaleString('en-US', { timeZone: timezone, minute: '2-digit' })
// 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')}`
}

View File

@@ -140,10 +140,14 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
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 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)
@@ -179,24 +183,13 @@ const loadSpaces = async () => {
}
const formatDate = (datetime: string): string => {
const date = new Date(datetime)
return date.toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric'
})
return formatDateUtil(datetime, userTimezone.value)
}
const formatTime = (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 startTime = formatTimeUtil(start, userTimezone.value)
const endTime = formatTimeUtil(end, userTimezone.value)
return `${startTime} - ${endTime}`
}
const formatType = (type: string): string => {

View File

@@ -73,10 +73,14 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { auditLogApi } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
import type { AuditLog } from '@/types'
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const logs = ref<AuditLog[]>([])
const page = ref(1)
const limit = 50
@@ -127,13 +131,7 @@ const nextPage = () => {
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('ro-RO', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
return formatDateTimeUtil(dateStr, userTimezone.value)
}
const formatAction = (action: string) => {

View File

@@ -1,22 +1,831 @@
<template>
<div class="dashboard">
<h2>Dashboard</h2>
<div class="card">
<p>Welcome to Space Booking System!</p>
<p>Use the navigation to explore available spaces and manage your bookings.</p>
<!-- Loading State -->
<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>
</template>
<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>
<style scoped>
.dashboard h2 {
margin-bottom: 1.5rem;
.dashboard {
max-width: 1400px;
margin: 0 auto;
}
.card p {
margin-bottom: 0.5rem;
.dashboard h2 {
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>

View File

@@ -191,7 +191,7 @@
import { ref, computed, onMounted } from 'vue'
import { bookingsApi, handleApiError } from '@/services/api'
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'
const authStore = useAuthStore()
@@ -237,18 +237,6 @@ const formatTime = (start: string, end: string): string => {
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 typeMap: Record<string, string> = {
sala: 'Sala',
@@ -263,15 +251,12 @@ const formatStatus = (status: string): string => {
const openEditModal = (booking: Booking) => {
editingBooking.value = booking
// Convert ISO datetime to datetime-local format (YYYY-MM-DDTHH:MM)
const start = new Date(booking.start_datetime)
const end = new Date(booking.end_datetime)
// Convert ISO datetime to datetime-local format in user's timezone
editForm.value = {
title: booking.title,
description: booking.description || '',
start_datetime: start.toISOString().slice(0, 16),
end_datetime: end.toISOString().slice(0, 16)
start_datetime: isoToLocalDateTime(booking.start_datetime, userTimezone.value),
end_datetime: isoToLocalDateTime(booking.end_datetime, userTimezone.value)
}
editError.value = ''
showEditModal.value = true
@@ -290,12 +275,12 @@ const saveEdit = async () => {
editError.value = ''
try {
// Convert datetime-local format back to ISO
// Convert datetime-local format to ISO without Z (backend will handle timezone conversion)
const updateData = {
title: editForm.value.title,
description: editForm.value.description,
start_datetime: new Date(editForm.value.start_datetime).toISOString(),
end_datetime: new Date(editForm.value.end_datetime).toISOString()
start_datetime: localDateTimeToISO(editForm.value.start_datetime),
end_datetime: localDateTimeToISO(editForm.value.end_datetime)
}
await bookingsApi.update(editingBooking.value.id, updateData)

View File

@@ -127,12 +127,14 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { usersApi, googleCalendarApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
import type { User } from '@/types'
const authStore = useAuthStore()
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
const user = ref<User | null>(null)
const loadingGoogleStatus = ref(true)
@@ -308,8 +310,7 @@ const disconnectGoogle = async () => {
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString()
return formatDateTimeUtil(dateString, userTimezone.value)
}
onMounted(() => {