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:
Claude Agent
2026-02-09 17:51:29 +00:00
commit df4031d99c
113 changed files with 24491 additions and 0 deletions

View 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>