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:
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user