feat: Space Booking System - MVP complet
Sistem web pentru rezervarea de birouri și săli de ședință cu flux de aprobare administrativă. Stack: FastAPI + Vue.js 3 + SQLite + TypeScript Features implementate: - Autentificare JWT + Self-registration cu email verification - CRUD Spații, Utilizatori, Settings (Admin) - Calendar interactiv (FullCalendar) cu drag-and-drop - Creare rezervări cu validare (durată, program, overlap, max/zi) - Rezervări recurente (săptămânal) - Admin: aprobare/respingere/anulare cereri - Admin: creare directă rezervări (bypass approval) - Admin: editare orice rezervare - User: editare/anulare rezervări proprii - Notificări in-app (bell icon + dropdown) - Notificări email (async SMTP cu BackgroundTasks) - Jurnal acțiuni administrative (audit log) - Rapoarte avansate (utilizare, top users, approval rate) - Șabloane rezervări (booking templates) - Atașamente fișiere (upload/download) - Conflict warnings (verificare disponibilitate real-time) - Integrare Google Calendar (OAuth2) - Suport timezone (UTC storage + user preference) - 225+ teste backend Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
527
frontend/src/views/UserProfile.vue
Normal file
527
frontend/src/views/UserProfile.vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<template>
|
||||
<div class="user-profile">
|
||||
<h2>User Profile</h2>
|
||||
|
||||
<!-- Profile Information Card -->
|
||||
<div class="card">
|
||||
<h3>Profile Information</h3>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Timezone Preferences Card -->
|
||||
<div class="card">
|
||||
<h3>Timezone Preferences</h3>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Google Calendar Integration Card -->
|
||||
<div class="card">
|
||||
<h3>Google Calendar Integration</h3>
|
||||
|
||||
<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">
|
||||
<span class="status-icon">✓</span>
|
||||
<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>
|
||||
|
||||
<button @click="disconnectGoogle" class="btn btn-danger" :disabled="disconnecting">
|
||||
{{ disconnecting ? 'Disconnecting...' : 'Disconnect Google Calendar' }}
|
||||
</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Info Card -->
|
||||
<div class="card info-card">
|
||||
<h4>About Calendar Integration</h4>
|
||||
<ul>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { usersApi, googleCalendarApi, handleApiError } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { User } from '@/types'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const user = ref<User | null>(null)
|
||||
const loadingGoogleStatus = ref(true)
|
||||
const connecting = ref(false)
|
||||
const disconnecting = ref(false)
|
||||
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)
|
||||
|
||||
// Update auth store
|
||||
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)
|
||||
// Revert selection on error
|
||||
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()
|
||||
|
||||
// Open OAuth URL in popup window
|
||||
const popup = window.open(
|
||||
response.authorization_url,
|
||||
'Google Calendar Authorization',
|
||||
'width=600,height=600,toolbar=no,menubar=no,location=no'
|
||||
)
|
||||
|
||||
// Poll for connection status
|
||||
const pollInterval = setInterval(async () => {
|
||||
// Check if popup was closed
|
||||
if (popup && popup.closed) {
|
||||
clearInterval(pollInterval)
|
||||
connecting.value = false
|
||||
|
||||
// Check status one more time
|
||||
await checkGoogleStatus()
|
||||
|
||||
if (googleStatus.value.connected) {
|
||||
success.value = 'Google Calendar connected successfully!'
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
}
|
||||
} else {
|
||||
// Poll for connection status
|
||||
try {
|
||||
const status = await googleCalendarApi.status()
|
||||
if (status.connected) {
|
||||
clearInterval(pollInterval)
|
||||
connecting.value = false
|
||||
googleStatus.value = status
|
||||
success.value = 'Google Calendar connected successfully!'
|
||||
|
||||
// Close popup
|
||||
if (popup) {
|
||||
popup.close()
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore polling errors
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// Stop polling after 5 minutes
|
||||
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 formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUser()
|
||||
loadTimezones()
|
||||
checkGoogleStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-profile {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.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: #555;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.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: #4caf50;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.expiry-info {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.benefits-list {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.benefits-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #4285f4;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #357ae8;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background-color: #fee;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 4px;
|
||||
color: #c33;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 0.75rem;
|
||||
background-color: #efe;
|
||||
border: 1px solid #cfc;
|
||||
border-radius: 4px;
|
||||
color: #3c3;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.info-card ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.info-card li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.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: #555;
|
||||
}
|
||||
|
||||
.timezone-select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
max-width: 400px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timezone-select:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user