- Dashboard redesign as command center with filters, quick actions, inline approve/reject - Reusable components: BookingRow, BookingFilters, ActionMenu, BookingPreviewModal, BookingEditModal - Calendar: drag & drop reschedule, eventClick preview modal, grid/list toggle - Mobile: segmented control bookings/calendar toggle, compact pills, responsive layout - Collapsible filters with active count badge - Smart menu positioning with Teleport - Calendar/list bidirectional data sync - Navigation: unified History page, removed AdminPending - Google Calendar OAuth integration - Dark mode contrast improvements, breadcrumb navigation - useLocalStorage composable for state persistence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
550 lines
13 KiB
Vue
550 lines
13 KiB
Vue
<template>
|
|
<div class="user-profile">
|
|
<Breadcrumb :items="breadcrumbItems" />
|
|
<h2>User Profile</h2>
|
|
|
|
<!-- Profile Information Card -->
|
|
<CollapsibleSection title="Profile Information" :icon="UserIcon">
|
|
<div v-if="user" class="profile-info">
|
|
<div class="info-item">
|
|
<label>Email:</label>
|
|
<span>{{ user.email }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Full Name:</label>
|
|
<span>{{ user.full_name }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Organization:</label>
|
|
<span>{{ user.organization || 'N/A' }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Role:</label>
|
|
<span>{{ user.role }}</span>
|
|
</div>
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
<!-- Timezone Preferences Card -->
|
|
<CollapsibleSection title="Timezone Preferences" :icon="Globe">
|
|
<div v-if="loadingTimezones" class="loading">Loading timezones...</div>
|
|
|
|
<div v-else class="timezone-settings">
|
|
<p class="info-text">
|
|
Select your timezone to see all booking times displayed in your local time.
|
|
</p>
|
|
|
|
<div class="form-group">
|
|
<label for="timezone">Timezone:</label>
|
|
<select
|
|
id="timezone"
|
|
v-model="selectedTimezone"
|
|
@change="updateTimezone"
|
|
class="timezone-select"
|
|
:disabled="updatingTimezone"
|
|
>
|
|
<option v-for="tz in availableTimezones" :key="tz" :value="tz">
|
|
{{ tz }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<p class="help-text">All times will be displayed in your timezone throughout the app.</p>
|
|
|
|
<div v-if="timezoneSuccess" class="success">{{ timezoneSuccess }}</div>
|
|
<div v-if="timezoneError" class="error">{{ timezoneError }}</div>
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
<!-- Google Calendar Integration Card -->
|
|
<CollapsibleSection title="Google Calendar Integration" :icon="CalendarDays">
|
|
<div v-if="loadingGoogleStatus" class="loading">Checking connection status...</div>
|
|
|
|
<div v-else>
|
|
<div v-if="googleStatus.connected" class="google-connected">
|
|
<div class="status-indicator">
|
|
<CheckCircle :size="20" class="status-icon-connected" />
|
|
<span>Connected to Google Calendar</span>
|
|
</div>
|
|
|
|
<p v-if="googleStatus.expires_at" class="expiry-info">
|
|
Token expires: {{ formatDate(googleStatus.expires_at) }}
|
|
</p>
|
|
|
|
<p class="info-text">
|
|
Your approved bookings will automatically sync to your Google Calendar.
|
|
</p>
|
|
|
|
<div class="button-group">
|
|
<button @click="syncGoogle" class="btn btn-primary" :disabled="syncing">
|
|
{{ syncing ? 'Syncing...' : 'Sync Now' }}
|
|
</button>
|
|
<button @click="disconnectGoogle" class="btn btn-danger" :disabled="disconnecting">
|
|
{{ disconnecting ? 'Disconnecting...' : 'Disconnect' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="syncResult" class="sync-result">
|
|
Synced {{ syncResult.synced }} bookings ({{ syncResult.created }} created, {{ syncResult.updated }} updated<span v-if="syncResult.failed">, {{ syncResult.failed }} failed</span>)
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="google-disconnected">
|
|
<p class="info-text">
|
|
Connect your Google Calendar to automatically sync approved bookings.
|
|
</p>
|
|
|
|
<ul class="benefits-list">
|
|
<li>Approved bookings are automatically added to your calendar</li>
|
|
<li>Canceled bookings are automatically removed</li>
|
|
<li>Stay organized with automatic calendar updates</li>
|
|
</ul>
|
|
|
|
<button @click="connectGoogle" class="btn btn-primary" :disabled="connecting">
|
|
{{ connecting ? 'Connecting...' : 'Connect Google Calendar' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="error" class="error">{{ error }}</div>
|
|
<div v-if="success" class="success">{{ success }}</div>
|
|
</CollapsibleSection>
|
|
|
|
<!-- Info Card -->
|
|
<CollapsibleSection title="About Calendar Integration" :icon="Info">
|
|
<ul class="info-list">
|
|
<li>
|
|
<strong>Automatic Sync:</strong> When your booking is approved, it's automatically added to
|
|
your Google Calendar
|
|
</li>
|
|
<li>
|
|
<strong>Updates:</strong> Canceled bookings are automatically removed from your calendar
|
|
</li>
|
|
<li><strong>Privacy:</strong> Only your bookings are synced, not other users' bookings</li>
|
|
<li>
|
|
<strong>Security:</strong> You can disconnect at any time by clicking the disconnect button
|
|
above
|
|
</li>
|
|
</ul>
|
|
</CollapsibleSection>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
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 Breadcrumb from '@/components/Breadcrumb.vue'
|
|
import CollapsibleSection from '@/components/CollapsibleSection.vue'
|
|
import { User as UserIcon, Globe, CalendarDays, CheckCircle, Info } from 'lucide-vue-next'
|
|
import type { User } from '@/types'
|
|
|
|
const breadcrumbItems = [
|
|
{ label: 'Dashboard', to: '/dashboard' },
|
|
{ label: 'Profile' }
|
|
]
|
|
|
|
const authStore = useAuthStore()
|
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
|
|
|
const user = ref<User | null>(null)
|
|
const loadingGoogleStatus = ref(true)
|
|
const connecting = ref(false)
|
|
const disconnecting = ref(false)
|
|
const syncing = ref(false)
|
|
const syncResult = ref<{ synced: number; created: number; updated: number; failed: number } | null>(null)
|
|
const error = ref('')
|
|
const success = ref('')
|
|
|
|
const googleStatus = ref<{ connected: boolean; expires_at: string | null }>({
|
|
connected: false,
|
|
expires_at: null
|
|
})
|
|
|
|
// Timezone state
|
|
const availableTimezones = ref<string[]>([])
|
|
const selectedTimezone = ref<string>('UTC')
|
|
const loadingTimezones = ref(true)
|
|
const updatingTimezone = ref(false)
|
|
const timezoneSuccess = ref('')
|
|
const timezoneError = ref('')
|
|
|
|
const loadUser = async () => {
|
|
try {
|
|
user.value = await usersApi.me()
|
|
selectedTimezone.value = user.value?.timezone || 'UTC'
|
|
} catch (err) {
|
|
error.value = handleApiError(err)
|
|
}
|
|
}
|
|
|
|
const loadTimezones = async () => {
|
|
try {
|
|
loadingTimezones.value = true
|
|
availableTimezones.value = await usersApi.getTimezones()
|
|
} catch (err) {
|
|
timezoneError.value = handleApiError(err)
|
|
} finally {
|
|
loadingTimezones.value = false
|
|
}
|
|
}
|
|
|
|
const updateTimezone = async () => {
|
|
timezoneError.value = ''
|
|
timezoneSuccess.value = ''
|
|
updatingTimezone.value = true
|
|
|
|
try {
|
|
await usersApi.updateTimezone(selectedTimezone.value)
|
|
|
|
if (authStore.user) {
|
|
authStore.user.timezone = selectedTimezone.value
|
|
}
|
|
|
|
timezoneSuccess.value = 'Timezone updated successfully! Refresh the page to see times in your timezone.'
|
|
setTimeout(() => {
|
|
timezoneSuccess.value = ''
|
|
}, 5000)
|
|
} catch (err) {
|
|
timezoneError.value = handleApiError(err)
|
|
if (user.value) {
|
|
selectedTimezone.value = user.value.timezone
|
|
}
|
|
} finally {
|
|
updatingTimezone.value = false
|
|
}
|
|
}
|
|
|
|
const checkGoogleStatus = async () => {
|
|
try {
|
|
loadingGoogleStatus.value = true
|
|
googleStatus.value = await googleCalendarApi.status()
|
|
} catch (err) {
|
|
error.value = handleApiError(err)
|
|
} finally {
|
|
loadingGoogleStatus.value = false
|
|
}
|
|
}
|
|
|
|
const connectGoogle = async () => {
|
|
error.value = ''
|
|
success.value = ''
|
|
connecting.value = true
|
|
|
|
try {
|
|
const response = await googleCalendarApi.connect()
|
|
|
|
const popup = window.open(
|
|
response.authorization_url,
|
|
'Google Calendar Authorization',
|
|
'width=600,height=600,toolbar=no,menubar=no,location=no'
|
|
)
|
|
|
|
const pollInterval = setInterval(async () => {
|
|
if (popup && popup.closed) {
|
|
clearInterval(pollInterval)
|
|
connecting.value = false
|
|
|
|
await checkGoogleStatus()
|
|
|
|
if (googleStatus.value.connected) {
|
|
success.value = 'Google Calendar connected successfully!'
|
|
setTimeout(() => {
|
|
success.value = ''
|
|
}, 3000)
|
|
}
|
|
} else {
|
|
try {
|
|
const status = await googleCalendarApi.status()
|
|
if (status.connected) {
|
|
clearInterval(pollInterval)
|
|
connecting.value = false
|
|
googleStatus.value = status
|
|
success.value = 'Google Calendar connected successfully!'
|
|
|
|
if (popup) {
|
|
popup.close()
|
|
}
|
|
|
|
setTimeout(() => {
|
|
success.value = ''
|
|
}, 3000)
|
|
}
|
|
} catch (err) {
|
|
// Ignore polling errors
|
|
}
|
|
}
|
|
}, 2000)
|
|
|
|
setTimeout(() => {
|
|
clearInterval(pollInterval)
|
|
connecting.value = false
|
|
if (popup && !popup.closed) {
|
|
popup.close()
|
|
}
|
|
}, 300000)
|
|
} catch (err) {
|
|
error.value = handleApiError(err)
|
|
connecting.value = false
|
|
}
|
|
}
|
|
|
|
const disconnectGoogle = async () => {
|
|
if (!confirm('Are you sure you want to disconnect Google Calendar?')) {
|
|
return
|
|
}
|
|
|
|
error.value = ''
|
|
success.value = ''
|
|
disconnecting.value = true
|
|
|
|
try {
|
|
await googleCalendarApi.disconnect()
|
|
googleStatus.value = { connected: false, expires_at: null }
|
|
success.value = 'Google Calendar disconnected successfully!'
|
|
|
|
setTimeout(() => {
|
|
success.value = ''
|
|
}, 3000)
|
|
} catch (err) {
|
|
error.value = handleApiError(err)
|
|
} finally {
|
|
disconnecting.value = false
|
|
}
|
|
}
|
|
|
|
const syncGoogle = async () => {
|
|
error.value = ''
|
|
success.value = ''
|
|
syncResult.value = null
|
|
syncing.value = true
|
|
|
|
try {
|
|
const result = await googleCalendarApi.sync()
|
|
syncResult.value = result
|
|
success.value = 'Calendar synced successfully!'
|
|
setTimeout(() => {
|
|
success.value = ''
|
|
syncResult.value = null
|
|
}, 5000)
|
|
} catch (err) {
|
|
error.value = handleApiError(err)
|
|
} finally {
|
|
syncing.value = false
|
|
}
|
|
}
|
|
|
|
const formatDate = (dateString: string): string => {
|
|
return formatDateTimeUtil(dateString, userTimezone.value)
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadUser()
|
|
loadTimezones()
|
|
checkGoogleStatus()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.user-profile {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
h2 {
|
|
margin-bottom: 1.5rem;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.profile-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.info-item {
|
|
display: flex;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.info-item label {
|
|
font-weight: 600;
|
|
min-width: 120px;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.info-item span {
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.google-connected,
|
|
.google-disconnected {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.status-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 1.1rem;
|
|
font-weight: 500;
|
|
color: var(--color-success);
|
|
}
|
|
|
|
.status-icon-connected {
|
|
color: var(--color-success);
|
|
}
|
|
|
|
.expiry-info {
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.9rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.info-text {
|
|
color: var(--color-text-secondary);
|
|
line-height: 1.6;
|
|
margin: 0;
|
|
}
|
|
|
|
.benefits-list {
|
|
margin: 0;
|
|
padding-left: 1.5rem;
|
|
}
|
|
|
|
.benefits-list li {
|
|
margin-bottom: 0.5rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.btn {
|
|
padding: 0.6rem 1.2rem;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
transition: all var(--transition-fast);
|
|
align-self: flex-start;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--color-accent);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
background: var(--color-accent-hover);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: var(--color-danger);
|
|
color: white;
|
|
}
|
|
|
|
.btn-danger:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--color-danger) 85%, black);
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.button-group {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.sync-result {
|
|
padding: 0.5rem 0.75rem;
|
|
background: color-mix(in srgb, var(--color-info) 10%, transparent);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-info);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.error {
|
|
padding: 0.75rem;
|
|
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-danger);
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.success {
|
|
padding: 0.75rem;
|
|
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-success);
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.info-list {
|
|
margin: 0;
|
|
padding-left: 1.5rem;
|
|
}
|
|
|
|
.info-list li {
|
|
margin-bottom: 0.5rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.info-list strong {
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.timezone-settings {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.form-group label {
|
|
font-weight: 600;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.timezone-select {
|
|
padding: 0.5rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 1rem;
|
|
max-width: 400px;
|
|
cursor: pointer;
|
|
background: var(--color-surface);
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.timezone-select:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.help-text {
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.9rem;
|
|
font-style: italic;
|
|
margin: 0;
|
|
}
|
|
|
|
.collapsible-section + .collapsible-section {
|
|
margin-top: 16px;
|
|
}
|
|
</style>
|