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",
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}`
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user