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:
389
frontend/src/App.vue
Normal file
389
frontend/src/App.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<header v-if="authStore.isAuthenticated" class="header">
|
||||
<div class="container">
|
||||
<h1>Space Booking</h1>
|
||||
<nav>
|
||||
<router-link to="/dashboard">Dashboard</router-link>
|
||||
<router-link to="/spaces">Spaces</router-link>
|
||||
<router-link to="/my-bookings">My Bookings</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin">Spaces Admin</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/users">Users Admin</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/pending">Pending Requests</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/settings">Settings</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/reports">Reports</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin/audit-log">Audit Log</router-link>
|
||||
|
||||
<!-- Notification Bell -->
|
||||
<div class="notification-wrapper">
|
||||
<button @click="toggleNotifications" class="notification-bell" aria-label="Notifications">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
||||
</svg>
|
||||
<span v-if="unreadCount > 0" class="badge">{{ unreadCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Notification Dropdown -->
|
||||
<div v-if="showNotifications" class="notification-dropdown" ref="dropdownRef">
|
||||
<div class="notification-header">
|
||||
<h3>Notifications</h3>
|
||||
<button @click="closeNotifications" class="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="notification-loading">Loading...</div>
|
||||
|
||||
<div v-else-if="notifications.length === 0" class="notification-empty">
|
||||
No new notifications
|
||||
</div>
|
||||
|
||||
<div v-else class="notification-list">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:class="['notification-item', { unread: !notification.is_read }]"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<div class="notification-title">{{ notification.title }}</div>
|
||||
<div class="notification-message">{{ notification.message }}</div>
|
||||
<div class="notification-time">{{ formatTime(notification.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="logout" class="btn-logout">Logout ({{ authStore.user?.email }})</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { notificationsApi } from '@/services/api'
|
||||
import type { Notification } from '@/types'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const notifications = ref<Notification[]>([])
|
||||
const showNotifications = ref(false)
|
||||
const loading = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
let refreshInterval: number | null = null
|
||||
|
||||
const unreadCount = computed(() => {
|
||||
return notifications.value.filter((n) => !n.is_read).length
|
||||
})
|
||||
|
||||
const logout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
if (!authStore.isAuthenticated) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
// Get all notifications, sorted by created_at DESC (from API)
|
||||
notifications.value = await notificationsApi.getAll()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notifications:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleNotifications = () => {
|
||||
showNotifications.value = !showNotifications.value
|
||||
if (showNotifications.value) {
|
||||
fetchNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
const closeNotifications = () => {
|
||||
showNotifications.value = false
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (notification: Notification) => {
|
||||
// Mark as read
|
||||
if (!notification.is_read) {
|
||||
try {
|
||||
await notificationsApi.markAsRead(notification.id)
|
||||
// Update local state
|
||||
notification.is_read = true
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notification as read:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to booking if available
|
||||
if (notification.booking_id) {
|
||||
closeNotifications()
|
||||
router.push('/my-bookings')
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
// Click outside to close
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.value &&
|
||||
!dropdownRef.value.contains(event.target as Node) &&
|
||||
!(event.target as HTMLElement).closest('.notification-bell')
|
||||
) {
|
||||
closeNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Initial fetch
|
||||
fetchNotifications()
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
refreshInterval = window.setInterval(fetchNotifications, 30000)
|
||||
|
||||
// Add click outside listener
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
}
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
nav a:hover,
|
||||
nav a.router-link-active {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.main {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
.notification-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-bell {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.notification-bell:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.notification-bell .badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
width: 360px;
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.notification-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #e0e0e0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.notification-loading,
|
||||
.notification-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
overflow-y: auto;
|
||||
max-height: 340px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background: #e8f4fd;
|
||||
border-left: 3px solid #3498db;
|
||||
}
|
||||
|
||||
.notification-item.unread:hover {
|
||||
background: #d6ebfa;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notification-item.unread .notification-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
color: #555;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
color: #95a5a6;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.notification-dropdown {
|
||||
width: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
126
frontend/src/assets/main.css
Normal file
126
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,126 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #2c3e50;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #229954;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-rejected {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.badge-canceled {
|
||||
background: #e2e3e5;
|
||||
color: #383d41;
|
||||
}
|
||||
237
frontend/src/components/AttachmentsList.vue
Normal file
237
frontend/src/components/AttachmentsList.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="attachments-list">
|
||||
<h4 class="attachments-title">Attachments</h4>
|
||||
|
||||
<div v-if="loading" class="loading">Loading attachments...</div>
|
||||
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="attachments.length === 0" class="no-attachments">No attachments</div>
|
||||
|
||||
<ul v-else class="attachment-items">
|
||||
<li v-for="attachment in attachments" :key="attachment.id" class="attachment-item">
|
||||
<div class="attachment-info">
|
||||
<span class="attachment-icon">📎</span>
|
||||
<div class="attachment-details">
|
||||
<a
|
||||
:href="getDownloadUrl(attachment.id)"
|
||||
class="attachment-name"
|
||||
target="_blank"
|
||||
:download="attachment.filename"
|
||||
>
|
||||
{{ attachment.filename }}
|
||||
</a>
|
||||
<span class="attachment-meta">
|
||||
{{ formatFileSize(attachment.size) }} · Uploaded by {{ attachment.uploader_name }} ·
|
||||
{{ formatDate(attachment.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
:disabled="deleting === attachment.id"
|
||||
@click="handleDelete(attachment.id)"
|
||||
>
|
||||
{{ deleting === attachment.id ? 'Deleting...' : 'Delete' }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { attachmentsApi, handleApiError } from '@/services/api'
|
||||
import type { Attachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
bookingId: number
|
||||
canDelete?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'deleted'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const attachments = ref<Attachment[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const deleting = ref<number | null>(null)
|
||||
|
||||
const loadAttachments = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
attachments.value = await attachmentsApi.list(props.bookingId)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (attachmentId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this attachment?')) {
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = attachmentId
|
||||
try {
|
||||
await attachmentsApi.delete(attachmentId)
|
||||
attachments.value = attachments.value.filter(a => a.id !== attachmentId)
|
||||
emit('deleted')
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
deleting.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const getDownloadUrl = (attachmentId: number): string => {
|
||||
return attachmentsApi.download(attachmentId)
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAttachments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attachments-list {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.attachments-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.no-attachments {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.no-attachments {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.attachment-items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.attachment-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachment-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
font-size: 14px;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.attachment-meta {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
padding: 6px 12px;
|
||||
background: white;
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.btn-delete:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.attachment-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1308
frontend/src/components/BookingForm.vue
Normal file
1308
frontend/src/components/BookingForm.vue
Normal file
File diff suppressed because it is too large
Load Diff
43
frontend/src/components/README.md
Normal file
43
frontend/src/components/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# SpaceCalendar Component
|
||||
|
||||
Component Vue.js 3 pentru afișarea rezervărilor unui spațiu folosind FullCalendar.
|
||||
|
||||
## Utilizare
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<h2>Rezervări pentru Sala A</h2>
|
||||
<SpaceCalendar :space-id="1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SpaceCalendar from '@/components/SpaceCalendar.vue'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `spaceId` | `number` | Yes | ID-ul spațiului pentru care se afișează rezervările |
|
||||
|
||||
## Features
|
||||
|
||||
- **View Switcher**: Month, Week, Day views
|
||||
- **Status Colors**:
|
||||
- Pending: Orange (#FFA500)
|
||||
- Approved: Green (#4CAF50)
|
||||
- Rejected: Red (#F44336)
|
||||
- Canceled: Gray (#9E9E9E)
|
||||
- **Auto-refresh**: Se încarcă automat rezervările când se schimbă data
|
||||
- **Responsive**: Se adaptează la dimensiunea ecranului
|
||||
|
||||
## API Integration
|
||||
|
||||
Componenta folosește `bookingsApi.getForSpace(spaceId, start, end)` pentru a încărca rezervările.
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
Componenta este complet type-safe și folosește interfețele din `/src/types/index.ts`.
|
||||
484
frontend/src/components/SpaceCalendar.vue
Normal file
484
frontend/src/components/SpaceCalendar.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<template>
|
||||
<div class="space-calendar">
|
||||
<div v-if="isEditable" class="admin-notice">
|
||||
Admin Mode: Drag approved bookings to reschedule
|
||||
</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" />
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
|
||||
<div class="modal-content">
|
||||
<h3>Confirm Reschedule</h3>
|
||||
<p>Reschedule this booking?</p>
|
||||
|
||||
<div class="time-comparison">
|
||||
<div class="old-time">
|
||||
<strong>Old Time:</strong><br />
|
||||
{{ formatDateTime(confirmModal.oldStart) }} - {{ formatDateTime(confirmModal.oldEnd) }}
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="new-time">
|
||||
<strong>New Time:</strong><br />
|
||||
{{ formatDateTime(confirmModal.newStart) }} - {{ formatDateTime(confirmModal.newEnd) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button @click="confirmReschedule" :disabled="modalLoading" class="btn-primary">
|
||||
{{ modalLoading ? 'Saving...' : 'Confirm' }}
|
||||
</button>
|
||||
<button @click="cancelReschedule" :disabled="modalLoading" class="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
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 type { Booking } from '@/types'
|
||||
|
||||
interface Props {
|
||||
spaceId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const bookings = ref<Booking[]>([])
|
||||
const loading = ref(false)
|
||||
const modalLoading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
interface ConfirmModal {
|
||||
show: boolean
|
||||
booking: any
|
||||
oldStart: Date | null
|
||||
oldEnd: Date | null
|
||||
newStart: Date | null
|
||||
newEnd: Date | null
|
||||
revertFunc: (() => void) | null
|
||||
}
|
||||
|
||||
const confirmModal = ref<ConfirmModal>({
|
||||
show: false,
|
||||
booking: null,
|
||||
oldStart: null,
|
||||
oldEnd: null,
|
||||
newStart: null,
|
||||
newEnd: null,
|
||||
revertFunc: null
|
||||
})
|
||||
|
||||
// Admin can edit, users see read-only
|
||||
const isEditable = computed(() => authStore.user?.role === 'admin')
|
||||
|
||||
// Status to color mapping
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: '#FFA500',
|
||||
approved: '#4CAF50',
|
||||
rejected: '#F44336',
|
||||
canceled: '#9E9E9E'
|
||||
}
|
||||
|
||||
// Convert bookings to FullCalendar events
|
||||
const events = computed<EventInput[]>(() => {
|
||||
return bookings.value.map((booking) => ({
|
||||
id: String(booking.id),
|
||||
title: booking.title,
|
||||
start: booking.start_datetime,
|
||||
end: booking.end_datetime,
|
||||
backgroundColor: STATUS_COLORS[booking.status] || '#9E9E9E',
|
||||
borderColor: STATUS_COLORS[booking.status] || '#9E9E9E',
|
||||
extendedProps: {
|
||||
status: booking.status,
|
||||
description: booking.description
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
// Handle event drop (drag)
|
||||
const handleEventDrop = (info: EventDropArg) => {
|
||||
const booking = info.event
|
||||
const oldStart = info.oldEvent.start
|
||||
const oldEnd = info.oldEvent.end
|
||||
const newStart = info.event.start
|
||||
const newEnd = info.event.end
|
||||
|
||||
// Show confirmation modal
|
||||
confirmModal.value = {
|
||||
show: true,
|
||||
booking: booking,
|
||||
oldStart: oldStart,
|
||||
oldEnd: oldEnd,
|
||||
newStart: newStart,
|
||||
newEnd: newEnd,
|
||||
revertFunc: info.revert
|
||||
}
|
||||
}
|
||||
|
||||
// Handle event resize
|
||||
const handleEventResize = (info: EventResizeDoneArg) => {
|
||||
const booking = info.event
|
||||
const oldStart = info.oldEvent.start
|
||||
const oldEnd = info.oldEvent.end
|
||||
const newStart = info.event.start
|
||||
const newEnd = info.event.end
|
||||
|
||||
// Show confirmation modal
|
||||
confirmModal.value = {
|
||||
show: true,
|
||||
booking: booking,
|
||||
oldStart: oldStart,
|
||||
oldEnd: oldEnd,
|
||||
newStart: newStart,
|
||||
newEnd: newEnd,
|
||||
revertFunc: info.revert
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm reschedule
|
||||
const confirmReschedule = async () => {
|
||||
if (!confirmModal.value.newStart || !confirmModal.value.newEnd) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
modalLoading.value = true
|
||||
|
||||
// Call reschedule API
|
||||
await adminBookingsApi.reschedule(parseInt(confirmModal.value.booking.id), {
|
||||
start_datetime: confirmModal.value.newStart.toISOString(),
|
||||
end_datetime: confirmModal.value.newEnd.toISOString()
|
||||
})
|
||||
|
||||
// Success - reload events
|
||||
await loadBookings(
|
||||
confirmModal.value.newStart < confirmModal.value.oldStart!
|
||||
? confirmModal.value.newStart
|
||||
: confirmModal.value.oldStart!,
|
||||
confirmModal.value.newEnd > confirmModal.value.oldEnd!
|
||||
? confirmModal.value.newEnd
|
||||
: confirmModal.value.oldEnd!
|
||||
)
|
||||
|
||||
confirmModal.value.show = false
|
||||
} catch (err: any) {
|
||||
// Error - revert the change
|
||||
if (confirmModal.value.revertFunc) {
|
||||
confirmModal.value.revertFunc()
|
||||
}
|
||||
|
||||
const errorMsg = err.response?.data?.detail || 'Failed to reschedule booking'
|
||||
error.value = errorMsg
|
||||
|
||||
// Clear error after 5 seconds
|
||||
setTimeout(() => {
|
||||
error.value = ''
|
||||
}, 5000)
|
||||
|
||||
confirmModal.value.show = false
|
||||
} finally {
|
||||
modalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel reschedule
|
||||
const cancelReschedule = () => {
|
||||
// Revert the visual change
|
||||
if (confirmModal.value.revertFunc) {
|
||||
confirmModal.value.revertFunc()
|
||||
}
|
||||
confirmModal.value.show = false
|
||||
}
|
||||
|
||||
// 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'
|
||||
})
|
||||
}
|
||||
|
||||
// Load bookings for a date range
|
||||
const loadBookings = async (start: Date, end: Date) => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const startStr = start.toISOString()
|
||||
const endStr = end.toISOString()
|
||||
bookings.value = await bookingsApi.getForSpace(props.spaceId, startStr, endStr)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle date range changes
|
||||
const handleDatesSet = (arg: DatesSetArg) => {
|
||||
loadBookings(arg.start, arg.end)
|
||||
}
|
||||
|
||||
// FullCalendar options
|
||||
const calendarOptions = computed<CalendarOptions>(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
||||
},
|
||||
events: events.value,
|
||||
datesSet: handleDatesSet,
|
||||
editable: isEditable.value, // Enable drag/resize for admins
|
||||
eventStartEditable: isEditable.value,
|
||||
eventDurationEditable: isEditable.value,
|
||||
selectable: false,
|
||||
selectMirror: true,
|
||||
dayMaxEvents: true,
|
||||
weekends: true,
|
||||
height: 'auto',
|
||||
eventTimeFormat: {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
},
|
||||
slotLabelFormat: {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
},
|
||||
// Drag callback
|
||||
eventDrop: handleEventDrop,
|
||||
// Resize callback
|
||||
eventResize: handleEventResize,
|
||||
// Event rendering
|
||||
eventDidMount: (info) => {
|
||||
// Only approved bookings are draggable
|
||||
if (info.event.extendedProps.status !== 'approved') {
|
||||
info.el.style.cursor = 'default'
|
||||
}
|
||||
},
|
||||
// Event allow callback
|
||||
eventAllow: (dropInfo, draggedEvent) => {
|
||||
// Only allow dragging approved bookings
|
||||
return draggedEvent.extendedProps.status === 'approved'
|
||||
}
|
||||
}))
|
||||
|
||||
// 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)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.space-calendar {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.admin-notice {
|
||||
background: #e3f2fd;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
color: #1976d2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
margin-bottom: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.time-comparison {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 20px 0;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.old-time,
|
||||
.new-time {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.old-time strong,
|
||||
.new-time strong {
|
||||
color: #374151;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 24px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #93c5fd;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* FullCalendar custom styles */
|
||||
:deep(.fc) {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
:deep(.fc-button) {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
:deep(.fc-button:hover) {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
:deep(.fc-button-active) {
|
||||
background: #1d4ed8 !important;
|
||||
border-color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
:deep(.fc-daygrid-day-number) {
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.fc-col-header-cell-cushion) {
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.fc-event) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.fc-event-title) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Draggable events styling */
|
||||
:deep(.fc-event.fc-draggable) {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
:deep(.fc-event:not(.fc-draggable)) {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
115
frontend/src/router/index.ts
Normal file
115
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Login from '@/views/Login.vue'
|
||||
import Dashboard from '@/views/Dashboard.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/verify',
|
||||
name: 'VerifyEmail',
|
||||
component: () => import('@/views/VerifyEmail.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: Dashboard,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/spaces',
|
||||
name: 'Spaces',
|
||||
component: () => import('@/views/Spaces.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/spaces/:id',
|
||||
name: 'SpaceDetail',
|
||||
component: () => import('@/views/SpaceDetail.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/my-bookings',
|
||||
name: 'MyBookings',
|
||||
component: () => import('@/views/MyBookings.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'UserProfile',
|
||||
component: () => import('@/views/UserProfile.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'Admin',
|
||||
component: () => import('@/views/Admin.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'Users',
|
||||
component: () => import('@/views/Users.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/settings',
|
||||
name: 'AdminSettings',
|
||||
component: () => import('@/views/Settings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/pending',
|
||||
name: 'AdminPending',
|
||||
component: () => import('@/views/AdminPending.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/audit-log',
|
||||
name: 'AuditLog',
|
||||
component: () => import('@/views/AuditLog.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/reports',
|
||||
name: 'AdminReports',
|
||||
component: () => import('@/views/AdminReports.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Navigation guard
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
next('/dashboard')
|
||||
} else if (to.path === '/login' && authStore.isAuthenticated) {
|
||||
next('/dashboard')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
370
frontend/src/services/api.ts
Normal file
370
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import type {
|
||||
LoginRequest,
|
||||
TokenResponse,
|
||||
UserRegister,
|
||||
RegistrationResponse,
|
||||
EmailVerificationRequest,
|
||||
VerificationResponse,
|
||||
Space,
|
||||
User,
|
||||
Settings,
|
||||
Booking,
|
||||
BookingCreate,
|
||||
BookingUpdate,
|
||||
BookingTemplate,
|
||||
BookingTemplateCreate,
|
||||
Notification,
|
||||
AuditLog,
|
||||
Attachment,
|
||||
RecurringBookingCreate,
|
||||
RecurringBookingResult,
|
||||
SpaceUsageReport,
|
||||
TopUsersReport,
|
||||
ApprovalRateReport
|
||||
} from '@/types'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// Add token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: async (credentials: LoginRequest): Promise<TokenResponse> => {
|
||||
const response = await api.post<TokenResponse>('/auth/login', credentials)
|
||||
return response.data
|
||||
},
|
||||
|
||||
register: async (data: UserRegister): Promise<RegistrationResponse> => {
|
||||
const response = await api.post<RegistrationResponse>('/auth/register', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
verifyEmail: async (data: EmailVerificationRequest): Promise<VerificationResponse> => {
|
||||
const response = await api.post<VerificationResponse>('/auth/verify', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
resendVerification: async (email: string): Promise<{ message: string }> => {
|
||||
const response = await api.post<{ message: string }>('/auth/resend-verification', null, {
|
||||
params: { email }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Users API
|
||||
export const usersApi = {
|
||||
me: async (): Promise<User> => {
|
||||
const response = await api.get<User>('/users/me')
|
||||
return response.data
|
||||
},
|
||||
|
||||
list: async (params?: { role?: string; organization?: string }): Promise<User[]> => {
|
||||
const response = await api.get<User[]>('/admin/users', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (
|
||||
data: Omit<User, 'id' | 'is_active'> & { password: string }
|
||||
): Promise<User> => {
|
||||
const response = await api.post<User>('/admin/users', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (
|
||||
id: number,
|
||||
data: Partial<Omit<User, 'id' | 'is_active'>>
|
||||
): Promise<User> => {
|
||||
const response = await api.put<User>(`/admin/users/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateStatus: async (id: number, is_active: boolean): Promise<User> => {
|
||||
const response = await api.patch<User>(`/admin/users/${id}/status`, { is_active })
|
||||
return response.data
|
||||
},
|
||||
|
||||
resetPassword: async (id: number, new_password: string): Promise<User> => {
|
||||
const response = await api.post<User>(`/admin/users/${id}/reset-password`, {
|
||||
new_password
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
getTimezones: async (): Promise<string[]> => {
|
||||
const response = await api.get<string[]>('/users/timezones')
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateTimezone: async (timezone: string): Promise<{ message: string; timezone: string }> => {
|
||||
const response = await api.put<{ message: string; timezone: string }>('/users/me/timezone', {
|
||||
timezone
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Spaces API
|
||||
export const spacesApi = {
|
||||
list: async (): Promise<Space[]> => {
|
||||
const response = await api.get<Space[]>('/spaces')
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (data: Omit<Space, 'id' | 'is_active'>): Promise<Space> => {
|
||||
const response = await api.post<Space>('/admin/spaces', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (id: number, data: Omit<Space, 'id' | 'is_active'>): Promise<Space> => {
|
||||
const response = await api.put<Space>(`/admin/spaces/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateStatus: async (id: number, is_active: boolean): Promise<Space> => {
|
||||
const response = await api.patch<Space>(`/admin/spaces/${id}/status`, { is_active })
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Settings API
|
||||
export const settingsApi = {
|
||||
get: async (): Promise<Settings> => {
|
||||
const response = await api.get<Settings>('/admin/settings')
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (data: Omit<Settings, 'id'>): Promise<Settings> => {
|
||||
const response = await api.put<Settings>('/admin/settings', data)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Bookings API
|
||||
export const bookingsApi = {
|
||||
getForSpace: async (spaceId: number, start: string, end: string): Promise<Booking[]> => {
|
||||
const response = await api.get<Booking[]>(`/spaces/${spaceId}/bookings`, {
|
||||
params: { start, end }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
checkAvailability: async (params: {
|
||||
space_id: number
|
||||
start_datetime: string
|
||||
end_datetime: string
|
||||
}) => {
|
||||
return api.get('/bookings/check-availability', { params })
|
||||
},
|
||||
|
||||
create: async (data: BookingCreate): Promise<Booking> => {
|
||||
const response = await api.post<Booking>('/bookings', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getMy: async (status?: string): Promise<Booking[]> => {
|
||||
const response = await api.get<Booking[]>('/bookings/my', {
|
||||
params: status ? { status } : {}
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (id: number, data: BookingUpdate): Promise<Booking> => {
|
||||
const response = await api.put<Booking>(`/bookings/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createRecurring: async (data: RecurringBookingCreate): Promise<RecurringBookingResult> => {
|
||||
const response = await api.post<RecurringBookingResult>('/bookings/recurring', data)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Admin Bookings API
|
||||
export const adminBookingsApi = {
|
||||
getPending: async (filters?: { space_id?: number; user_id?: number }): Promise<Booking[]> => {
|
||||
const response = await api.get<Booking[]>('/admin/bookings/pending', { params: filters })
|
||||
return response.data
|
||||
},
|
||||
|
||||
approve: async (id: number): Promise<Booking> => {
|
||||
const response = await api.put<Booking>(`/admin/bookings/${id}/approve`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
reject: async (id: number, reason?: string): Promise<Booking> => {
|
||||
const response = await api.put<Booking>(`/admin/bookings/${id}/reject`, { reason })
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (id: number, data: BookingUpdate): Promise<Booking> => {
|
||||
const response = await api.put<Booking>(`/admin/bookings/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
reschedule: async (
|
||||
id: number,
|
||||
data: { start_datetime: string; end_datetime: string }
|
||||
): Promise<Booking> => {
|
||||
const response = await api.put<Booking>(`/admin/bookings/${id}/reschedule`, data)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications API
|
||||
export const notificationsApi = {
|
||||
getAll: async (isRead?: boolean): Promise<Notification[]> => {
|
||||
const params = isRead !== undefined ? { is_read: isRead } : {}
|
||||
const response = await api.get<Notification[]>('/notifications', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
markAsRead: async (id: number): Promise<Notification> => {
|
||||
const response = await api.put<Notification>(`/notifications/${id}/read`)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Audit Log API
|
||||
export const auditLogApi = {
|
||||
getAll: async (params?: {
|
||||
action?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
}): Promise<AuditLog[]> => {
|
||||
const response = await api.get<AuditLog[]>('/admin/audit-log', { params })
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Booking Templates API
|
||||
export const bookingTemplatesApi = {
|
||||
getAll: async (): Promise<BookingTemplate[]> => {
|
||||
const response = await api.get<BookingTemplate[]>('/booking-templates')
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (data: BookingTemplateCreate): Promise<BookingTemplate> => {
|
||||
const response = await api.post<BookingTemplate>('/booking-templates', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/booking-templates/${id}`)
|
||||
},
|
||||
|
||||
createBookingFromTemplate: async (
|
||||
templateId: number,
|
||||
startDatetime: string
|
||||
): Promise<Booking> => {
|
||||
const response = await api.post<Booking>(
|
||||
`/booking-templates/from-template/${templateId}`,
|
||||
null,
|
||||
{ params: { start_datetime: startDatetime } }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments API
|
||||
export const attachmentsApi = {
|
||||
upload: async (bookingId: number, file: File): Promise<Attachment> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const response = await api.post<Attachment>(`/bookings/${bookingId}/attachments`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
list: async (bookingId: number): Promise<Attachment[]> => {
|
||||
const response = await api.get<Attachment[]>(`/bookings/${bookingId}/attachments`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
download: (attachmentId: number): string => {
|
||||
return `/api/attachments/${attachmentId}/download`
|
||||
},
|
||||
|
||||
delete: async (attachmentId: number): Promise<void> => {
|
||||
await api.delete(`/attachments/${attachmentId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Reports API
|
||||
export const reportsApi = {
|
||||
getUsage: async (params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
space_id?: number
|
||||
}): Promise<SpaceUsageReport> => {
|
||||
const response = await api.get<SpaceUsageReport>('/admin/reports/usage', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
getTopUsers: async (params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
limit?: number
|
||||
}): Promise<TopUsersReport> => {
|
||||
const response = await api.get<TopUsersReport>('/admin/reports/top-users', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
getApprovalRate: async (params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}): Promise<ApprovalRateReport> => {
|
||||
const response = await api.get<ApprovalRateReport>('/admin/reports/approval-rate', {
|
||||
params
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Google Calendar API
|
||||
export const googleCalendarApi = {
|
||||
connect: async (): Promise<{ authorization_url: string; state: string }> => {
|
||||
const response = await api.get<{ authorization_url: string; state: string }>(
|
||||
'/integrations/google/connect'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
disconnect: async (): Promise<{ message: string }> => {
|
||||
const response = await api.delete<{ message: string }>('/integrations/google/disconnect')
|
||||
return response.data
|
||||
},
|
||||
|
||||
status: async (): Promise<{ connected: boolean; expires_at: string | null }> => {
|
||||
const response = await api.get<{ connected: boolean; expires_at: string | null }>(
|
||||
'/integrations/google/status'
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to handle API errors
|
||||
export const handleApiError = (error: unknown): string => {
|
||||
if (error instanceof AxiosError) {
|
||||
return error.response?.data?.detail || error.message
|
||||
}
|
||||
return 'An unexpected error occurred'
|
||||
}
|
||||
|
||||
export default api
|
||||
50
frontend/src/stores/auth.ts
Normal file
50
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authApi, usersApi } from '@/services/api'
|
||||
import type { User, LoginRequest } from '@/types'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('token'))
|
||||
const user = ref<User | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
const login = async (credentials: LoginRequest) => {
|
||||
const response = await authApi.login(credentials)
|
||||
token.value = response.access_token
|
||||
localStorage.setItem('token', response.access_token)
|
||||
|
||||
// Fetch user data from API
|
||||
user.value = await usersApi.me()
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
token.value = null
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
// Initialize user from token on page load
|
||||
const initFromToken = async () => {
|
||||
if (token.value) {
|
||||
try {
|
||||
user.value = await usersApi.me()
|
||||
} catch (error) {
|
||||
// Invalid token
|
||||
logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initFromToken()
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
login,
|
||||
logout
|
||||
}
|
||||
})
|
||||
219
frontend/src/types/index.ts
Normal file
219
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
full_name: string
|
||||
role: string
|
||||
organization?: string
|
||||
is_active: boolean
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export interface UserRegister {
|
||||
email: string
|
||||
password: string
|
||||
confirm_password: string
|
||||
full_name: string
|
||||
organization: string
|
||||
}
|
||||
|
||||
export interface RegistrationResponse {
|
||||
message: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface EmailVerificationRequest {
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface VerificationResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface Space {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
capacity: number
|
||||
description?: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
id: number
|
||||
space_id: number
|
||||
user_id: number
|
||||
start_datetime: string
|
||||
end_datetime: string
|
||||
title: string
|
||||
description?: string
|
||||
status: 'pending' | 'approved' | 'rejected' | 'canceled'
|
||||
created_at: string
|
||||
space?: Space
|
||||
user?: User
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
id: number
|
||||
min_duration_minutes: number
|
||||
max_duration_minutes: number
|
||||
working_hours_start: number
|
||||
working_hours_end: number
|
||||
max_bookings_per_day_per_user: number
|
||||
min_hours_before_cancel: number
|
||||
}
|
||||
|
||||
export interface BookingCreate {
|
||||
space_id: number
|
||||
start_datetime: string // ISO format
|
||||
end_datetime: string // ISO format
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface BookingUpdate {
|
||||
title?: string
|
||||
description?: string
|
||||
start_datetime?: string // ISO format
|
||||
end_datetime?: string // ISO format
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: number
|
||||
user_id: number
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
booking_id?: number
|
||||
is_read: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: number
|
||||
action: string
|
||||
user_id: number
|
||||
user_name: string
|
||||
user_email: string
|
||||
target_type: string
|
||||
target_id: number
|
||||
details?: Record<string, any>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ConflictingBooking {
|
||||
id: number
|
||||
user_name: string
|
||||
title: string
|
||||
status: string
|
||||
start_datetime: string
|
||||
end_datetime: string
|
||||
}
|
||||
|
||||
export interface AvailabilityCheck {
|
||||
available: boolean
|
||||
conflicts: ConflictingBooking[]
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface BookingTemplate {
|
||||
id: number
|
||||
user_id: number
|
||||
name: string
|
||||
space_id?: number
|
||||
space_name?: string
|
||||
duration_minutes: number
|
||||
title: string
|
||||
description?: string
|
||||
usage_count: number
|
||||
}
|
||||
|
||||
export interface BookingTemplateCreate {
|
||||
name: string
|
||||
space_id?: number
|
||||
duration_minutes: number
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number
|
||||
booking_id: number
|
||||
filename: string
|
||||
size: number
|
||||
content_type: string
|
||||
uploaded_by: number
|
||||
uploader_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface RecurringBookingCreate {
|
||||
space_id: number
|
||||
start_time: string
|
||||
duration_minutes: number
|
||||
title: string
|
||||
description?: string
|
||||
recurrence_days: number[]
|
||||
start_date: string
|
||||
end_date: string
|
||||
skip_conflicts: boolean
|
||||
}
|
||||
|
||||
export interface RecurringBookingResult {
|
||||
total_requested: number
|
||||
total_created: number
|
||||
total_skipped: number
|
||||
created_bookings: Booking[]
|
||||
skipped_dates: Array<{ date: string; reason: string }>
|
||||
}
|
||||
|
||||
export interface SpaceUsageItem {
|
||||
space_id: number
|
||||
space_name: string
|
||||
total_bookings: number
|
||||
approved_bookings: number
|
||||
pending_bookings: number
|
||||
rejected_bookings: number
|
||||
canceled_bookings: number
|
||||
total_hours: number
|
||||
}
|
||||
|
||||
export interface SpaceUsageReport {
|
||||
items: SpaceUsageItem[]
|
||||
total_bookings: number
|
||||
date_range: { start: string | null; end: string | null }
|
||||
}
|
||||
|
||||
export interface TopUserItem {
|
||||
user_id: number
|
||||
user_name: string
|
||||
user_email: string
|
||||
total_bookings: number
|
||||
approved_bookings: number
|
||||
total_hours: number
|
||||
}
|
||||
|
||||
export interface TopUsersReport {
|
||||
items: TopUserItem[]
|
||||
date_range: { start: string | null; end: string | null }
|
||||
}
|
||||
|
||||
export interface ApprovalRateReport {
|
||||
total_requests: number
|
||||
approved: number
|
||||
rejected: number
|
||||
pending: number
|
||||
canceled: number
|
||||
approval_rate: number
|
||||
rejection_rate: number
|
||||
date_range: { start: string | null; end: string | null }
|
||||
}
|
||||
117
frontend/src/utils/datetime.ts
Normal file
117
frontend/src/utils/datetime.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Utility functions for timezone-aware datetime formatting.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a datetime string in the user's timezone.
|
||||
*
|
||||
* @param datetime - ISO datetime string from API (in UTC)
|
||||
* @param timezone - IANA timezone string (e.g., "Europe/Bucharest")
|
||||
* @param options - Intl.DateTimeFormat options
|
||||
* @returns Formatted datetime string
|
||||
*/
|
||||
export const formatDateTime = (
|
||||
datetime: string,
|
||||
timezone: string = 'UTC',
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string => {
|
||||
const date = new Date(datetime)
|
||||
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
...options
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ro-RO', defaultOptions).format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date only (no time) in user's timezone.
|
||||
*/
|
||||
export const formatDate = (datetime: string, timezone: string = 'UTC'): string => {
|
||||
return formatDateTime(datetime, timezone, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: undefined,
|
||||
minute: undefined
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time only in user's timezone.
|
||||
*/
|
||||
export const formatTime = (datetime: string, timezone: string = 'UTC'): string => {
|
||||
const date = new Date(datetime)
|
||||
return new Intl.DateTimeFormat('ro-RO', {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format datetime with timezone abbreviation.
|
||||
*/
|
||||
export const formatDateTimeWithTZ = (datetime: string, timezone: string = 'UTC'): string => {
|
||||
const date = new Date(datetime)
|
||||
|
||||
const formatted = new Intl.DateTimeFormat('ro-RO', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
}).format(date)
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timezone abbreviation (e.g., "EET", "EEST").
|
||||
*/
|
||||
export const getTimezoneAbbr = (timezone: string = 'UTC'): string => {
|
||||
const date = new Date()
|
||||
const formatted = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short'
|
||||
}).format(date)
|
||||
|
||||
// Extract timezone abbreviation from formatted string
|
||||
const match = formatted.match(/,\s*(.+)/)
|
||||
return match ? match[1] : timezone.split('/').pop() || 'UTC'
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert local datetime-local input to ISO string (for API).
|
||||
* The input from datetime-local is in user's local time, so we just add seconds and Z.
|
||||
*/
|
||||
export const localDateTimeToISO = (localDateTime: string): string => {
|
||||
// datetime-local format: "YYYY-MM-DDTHH:mm"
|
||||
// We need to send it as is to the API (API will handle timezone conversion)
|
||||
return localDateTime + ':00'
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ISO datetime to datetime-local format for input field.
|
||||
*/
|
||||
export const isoToLocalDateTime = (isoDateTime: string, timezone: string = 'UTC'): string => {
|
||||
const date = new Date(isoDateTime)
|
||||
|
||||
// Get the date components in the user's timezone
|
||||
const year = date.toLocaleString('en-US', { timeZone: timezone, year: 'numeric' })
|
||||
const month = date.toLocaleString('en-US', { timeZone: timezone, month: '2-digit' })
|
||||
const day = date.toLocaleString('en-US', { timeZone: timezone, day: '2-digit' })
|
||||
const hour = date.toLocaleString('en-US', { timeZone: timezone, hour: '2-digit', hour12: false })
|
||||
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}`
|
||||
}
|
||||
411
frontend/src/views/Admin.vue
Normal file
411
frontend/src/views/Admin.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<template>
|
||||
<div class="admin">
|
||||
<h2>Admin Dashboard - Space Management</h2>
|
||||
|
||||
<!-- Create/Edit Form -->
|
||||
<div class="card">
|
||||
<h3>{{ editingSpace ? 'Edit Space' : 'Create New Space' }}</h3>
|
||||
<form @submit.prevent="handleSubmit" class="space-form">
|
||||
<div class="form-group">
|
||||
<label for="name">Name *</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Conference Room A"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="type">Type *</label>
|
||||
<select id="type" v-model="formData.type" required>
|
||||
<option value="sala">Sala</option>
|
||||
<option value="birou">Birou</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="capacity">Capacity *</label>
|
||||
<input
|
||||
id="capacity"
|
||||
v-model.number="formData.capacity"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
rows="3"
|
||||
placeholder="Optional description..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ editingSpace ? 'Update' : 'Create' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editingSpace"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="success" class="success">{{ success }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Spaces List -->
|
||||
<div class="card">
|
||||
<h3>All Spaces</h3>
|
||||
<div v-if="loadingSpaces" class="loading">Loading spaces...</div>
|
||||
<div v-else-if="spaces.length === 0" class="empty">
|
||||
No spaces created yet. Create one above!
|
||||
</div>
|
||||
<table v-else class="spaces-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Capacity</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="space in spaces" :key="space.id">
|
||||
<td>{{ space.name }}</td>
|
||||
<td>{{ space.type === 'sala' ? 'Sala' : 'Birou' }}</td>
|
||||
<td>{{ space.capacity }}</td>
|
||||
<td>
|
||||
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
|
||||
{{ space.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
@click="startEdit(space)"
|
||||
:disabled="loading"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
:class="['btn', 'btn-sm', space.is_active ? 'btn-warning' : 'btn-success']"
|
||||
@click="toggleStatus(space)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ space.is_active ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { spacesApi, handleApiError } from '@/services/api'
|
||||
import type { Space } from '@/types'
|
||||
|
||||
const spaces = ref<Space[]>([])
|
||||
const loadingSpaces = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const editingSpace = ref<Space | null>(null)
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
type: 'sala',
|
||||
capacity: 1,
|
||||
description: ''
|
||||
})
|
||||
|
||||
const loadSpaces = async () => {
|
||||
loadingSpaces.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
spaces.value = await spacesApi.list()
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loadingSpaces.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
if (editingSpace.value) {
|
||||
await spacesApi.update(editingSpace.value.id, formData.value)
|
||||
success.value = 'Space updated successfully!'
|
||||
} else {
|
||||
await spacesApi.create(formData.value)
|
||||
success.value = 'Space created successfully!'
|
||||
}
|
||||
|
||||
resetForm()
|
||||
await loadSpaces()
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (space: Space) => {
|
||||
editingSpace.value = space
|
||||
formData.value = {
|
||||
name: space.name,
|
||||
type: space.type,
|
||||
capacity: space.capacity,
|
||||
description: space.description || ''
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
editingSpace.value = null
|
||||
formData.value = {
|
||||
name: '',
|
||||
type: 'sala',
|
||||
capacity: 1,
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = async (space: Space) => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
await spacesApi.updateStatus(space.id, !space.is_active)
|
||||
success.value = `Space ${space.is_active ? 'deactivated' : 'activated'} successfully!`
|
||||
await loadSpaces()
|
||||
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSpaces()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.space-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 12px;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border-radius: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.spaces-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.spaces-table th {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.spaces-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.spaces-table tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
513
frontend/src/views/AdminPending.vue
Normal file
513
frontend/src/views/AdminPending.vue
Normal file
@@ -0,0 +1,513 @@
|
||||
<template>
|
||||
<div class="admin-pending">
|
||||
<h2>Admin Dashboard - Pending Booking Requests</h2>
|
||||
|
||||
<!-- Filters Card -->
|
||||
<div class="card">
|
||||
<h3>Filters</h3>
|
||||
<div class="filters">
|
||||
<div class="form-group">
|
||||
<label for="filter-space">Filter by Space</label>
|
||||
<select id="filter-space" v-model="filterSpaceId" @change="loadPendingBookings">
|
||||
<option value="">All Spaces</option>
|
||||
<option v-for="space in spaces" :key="space.id" :value="space.id">
|
||||
{{ space.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="card">
|
||||
<div class="loading">Loading pending requests...</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="bookings.length === 0" class="card">
|
||||
<div class="empty">
|
||||
No pending requests found.
|
||||
{{ filterSpaceId ? 'Try different filters.' : 'All bookings have been processed.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bookings Table -->
|
||||
<div v-else class="card">
|
||||
<h3>Pending Requests ({{ bookings.length }})</h3>
|
||||
<table class="bookings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Space</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Title</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="booking in bookings" :key="booking.id">
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ booking.user?.full_name || 'Unknown' }}</div>
|
||||
<div class="user-email">{{ booking.user?.email || '-' }}</div>
|
||||
<div class="user-org" v-if="booking.user?.organization">
|
||||
{{ booking.user.organization }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="space-info">
|
||||
<div class="space-name">{{ booking.space?.name || 'Unknown Space' }}</div>
|
||||
<div class="space-type">{{ formatType(booking.space?.type || '') }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatDate(booking.start_datetime) }}</td>
|
||||
<td>{{ formatTime(booking.start_datetime, booking.end_datetime) }}</td>
|
||||
<td>{{ booking.title }}</td>
|
||||
<td>
|
||||
<div class="description" :title="booking.description || '-'">
|
||||
{{ truncateText(booking.description || '-', 40) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
@click="handleApprove(booking)"
|
||||
:disabled="processing === booking.id"
|
||||
>
|
||||
{{ processing === booking.id ? 'Processing...' : 'Approve' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
@click="showRejectModal(booking)"
|
||||
:disabled="processing === booking.id"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Reject Modal -->
|
||||
<div v-if="rejectingBooking" class="modal" @click.self="closeRejectModal">
|
||||
<div class="modal-content">
|
||||
<h3>Reject Booking Request</h3>
|
||||
<div class="booking-summary">
|
||||
<p><strong>User:</strong> {{ rejectingBooking.user?.full_name }}</p>
|
||||
<p><strong>Space:</strong> {{ rejectingBooking.space?.name }}</p>
|
||||
<p><strong>Title:</strong> {{ rejectingBooking.title }}</p>
|
||||
<p>
|
||||
<strong>Date:</strong> {{ formatDate(rejectingBooking.start_datetime) }} -
|
||||
{{ formatTime(rejectingBooking.start_datetime, rejectingBooking.end_datetime) }}
|
||||
</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleReject">
|
||||
<div class="form-group">
|
||||
<label for="reject_reason">Rejection Reason (optional)</label>
|
||||
<textarea
|
||||
id="reject_reason"
|
||||
v-model="rejectReason"
|
||||
rows="4"
|
||||
placeholder="Provide a reason for rejection..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger" :disabled="processing !== null">
|
||||
{{ processing !== null ? 'Rejecting...' : 'Confirm Rejection' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" @click="closeRejectModal">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="card">
|
||||
<div class="error">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="success" class="card">
|
||||
<div class="success">{{ success }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { adminBookingsApi, spacesApi, handleApiError } from '@/services/api'
|
||||
import type { Booking, Space } from '@/types'
|
||||
|
||||
const bookings = ref<Booking[]>([])
|
||||
const spaces = ref<Space[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const processing = ref<number | null>(null)
|
||||
const filterSpaceId = ref<string>('')
|
||||
const rejectingBooking = ref<Booking | null>(null)
|
||||
const rejectReason = ref('')
|
||||
|
||||
const loadPendingBookings = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const filters: { space_id?: number } = {}
|
||||
if (filterSpaceId.value) {
|
||||
filters.space_id = Number(filterSpaceId.value)
|
||||
}
|
||||
bookings.value = await adminBookingsApi.getPending(filters)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadSpaces = async () => {
|
||||
try {
|
||||
spaces.value = await spacesApi.list()
|
||||
} catch (err) {
|
||||
console.error('Failed to load spaces:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (datetime: string): string => {
|
||||
const date = new Date(datetime)
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
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 formatType = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
sala: 'Sala',
|
||||
birou: 'Birou'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
const truncateText = (text: string, maxLength: number): string => {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
const handleApprove = async (booking: Booking) => {
|
||||
if (!confirm('Are you sure you want to approve this booking?')) {
|
||||
return
|
||||
}
|
||||
|
||||
processing.value = booking.id
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
await adminBookingsApi.approve(booking.id)
|
||||
success.value = `Booking "${booking.title}" approved successfully!`
|
||||
|
||||
// Remove from list
|
||||
bookings.value = bookings.value.filter((b) => b.id !== booking.id)
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
processing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const showRejectModal = (booking: Booking) => {
|
||||
rejectingBooking.value = booking
|
||||
rejectReason.value = ''
|
||||
}
|
||||
|
||||
const closeRejectModal = () => {
|
||||
rejectingBooking.value = null
|
||||
rejectReason.value = ''
|
||||
}
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!rejectingBooking.value) return
|
||||
|
||||
processing.value = rejectingBooking.value.id
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
await adminBookingsApi.reject(
|
||||
rejectingBooking.value.id,
|
||||
rejectReason.value || undefined
|
||||
)
|
||||
success.value = `Booking "${rejectingBooking.value.title}" rejected successfully!`
|
||||
|
||||
// Remove from list
|
||||
bookings.value = bookings.value.filter((b) => b.id !== rejectingBooking.value!.id)
|
||||
|
||||
closeRejectModal()
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
processing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSpaces()
|
||||
loadPendingBookings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-pending {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 12px;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bookings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.bookings-table th {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bookings-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.bookings-table tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.user-info,
|
||||
.space-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-name,
|
||||
.space-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.user-email,
|
||||
.user-org,
|
||||
.space-type {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 200px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.booking-summary {
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.booking-summary p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.booking-summary strong {
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
515
frontend/src/views/AdminReports.vue
Normal file
515
frontend/src/views/AdminReports.vue
Normal file
@@ -0,0 +1,515 @@
|
||||
<template>
|
||||
<div class="admin-reports">
|
||||
<h2>Booking Reports</h2>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div class="filters">
|
||||
<label>
|
||||
Start Date:
|
||||
<input type="date" v-model="startDate" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
End Date:
|
||||
<input type="date" v-model="endDate" />
|
||||
</label>
|
||||
|
||||
<button @click="loadReports" class="btn-primary">Refresh</button>
|
||||
<button @click="clearFilters" class="btn-secondary">Clear Filters</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="loading">Loading reports...</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div v-if="!loading && !error" class="tabs">
|
||||
<button
|
||||
@click="activeTab = 'usage'"
|
||||
:class="{ active: activeTab === 'usage' }"
|
||||
class="tab-button"
|
||||
>
|
||||
Space Usage
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'users'"
|
||||
:class="{ active: activeTab === 'users' }"
|
||||
class="tab-button"
|
||||
>
|
||||
Top Users
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'approval'"
|
||||
:class="{ active: activeTab === 'approval' }"
|
||||
class="tab-button"
|
||||
>
|
||||
Approval Rate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Usage Report -->
|
||||
<div v-if="activeTab === 'usage' && !loading" class="report-content">
|
||||
<h3>Space Usage Report</h3>
|
||||
<canvas ref="usageChart"></canvas>
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Space</th>
|
||||
<th>Total</th>
|
||||
<th>Approved</th>
|
||||
<th>Pending</th>
|
||||
<th>Rejected</th>
|
||||
<th>Canceled</th>
|
||||
<th>Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in usageReport?.items" :key="item.space_id">
|
||||
<td>{{ item.space_name }}</td>
|
||||
<td>{{ item.total_bookings }}</td>
|
||||
<td class="status-approved">{{ item.approved_bookings }}</td>
|
||||
<td class="status-pending">{{ item.pending_bookings }}</td>
|
||||
<td class="status-rejected">{{ item.rejected_bookings }}</td>
|
||||
<td class="status-canceled">{{ item.canceled_bookings }}</td>
|
||||
<td>{{ item.total_hours.toFixed(1) }}h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>Total</strong></td>
|
||||
<td><strong>{{ usageReport?.total_bookings }}</strong></td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Top Users Report -->
|
||||
<div v-if="activeTab === 'users' && !loading" class="report-content">
|
||||
<h3>Top Users Report</h3>
|
||||
<canvas ref="usersChart"></canvas>
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Total Bookings</th>
|
||||
<th>Approved</th>
|
||||
<th>Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in topUsersReport?.items" :key="item.user_id">
|
||||
<td>{{ item.user_name }}</td>
|
||||
<td>{{ item.user_email }}</td>
|
||||
<td>{{ item.total_bookings }}</td>
|
||||
<td class="status-approved">{{ item.approved_bookings }}</td>
|
||||
<td>{{ item.total_hours.toFixed(1) }}h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Approval Rate Report -->
|
||||
<div v-if="activeTab === 'approval' && !loading" class="report-content">
|
||||
<h3>Approval Rate Report</h3>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<h3>{{ approvalReport?.total_requests }}</h3>
|
||||
<p>Total Requests</p>
|
||||
</div>
|
||||
<div class="stat-card approved">
|
||||
<h3>{{ approvalReport?.approval_rate }}%</h3>
|
||||
<p>Approval Rate</p>
|
||||
</div>
|
||||
<div class="stat-card rejected">
|
||||
<h3>{{ approvalReport?.rejection_rate }}%</h3>
|
||||
<p>Rejection Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
<canvas ref="approvalChart"></canvas>
|
||||
<div class="breakdown">
|
||||
<p><strong>Approved:</strong> {{ approvalReport?.approved }}</p>
|
||||
<p><strong>Rejected:</strong> {{ approvalReport?.rejected }}</p>
|
||||
<p><strong>Pending:</strong> {{ approvalReport?.pending }}</p>
|
||||
<p><strong>Canceled:</strong> {{ approvalReport?.canceled }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { reportsApi } from '@/services/api'
|
||||
import Chart from 'chart.js/auto'
|
||||
import type { SpaceUsageReport, TopUsersReport, ApprovalRateReport } from '@/types'
|
||||
|
||||
const activeTab = ref('usage')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const usageReport = ref<SpaceUsageReport | null>(null)
|
||||
const topUsersReport = ref<TopUsersReport | null>(null)
|
||||
const approvalReport = ref<ApprovalRateReport | null>(null)
|
||||
|
||||
const usageChart = ref<HTMLCanvasElement | null>(null)
|
||||
const usersChart = ref<HTMLCanvasElement | null>(null)
|
||||
const approvalChart = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
let usageChartInstance: Chart | null = null
|
||||
let usersChartInstance: Chart | null = null
|
||||
let approvalChartInstance: Chart | null = null
|
||||
|
||||
const loadReports = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const params = {
|
||||
start_date: startDate.value || undefined,
|
||||
end_date: endDate.value || undefined
|
||||
}
|
||||
|
||||
usageReport.value = await reportsApi.getUsage(params)
|
||||
topUsersReport.value = await reportsApi.getTopUsers(params)
|
||||
approvalReport.value = await reportsApi.getApprovalRate(params)
|
||||
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.detail || 'Failed to load reports'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
loadReports()
|
||||
}
|
||||
|
||||
const renderCharts = () => {
|
||||
// Render usage chart (bar chart)
|
||||
if (usageChart.value && usageReport.value) {
|
||||
if (usageChartInstance) {
|
||||
usageChartInstance.destroy()
|
||||
}
|
||||
|
||||
usageChartInstance = new Chart(usageChart.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: usageReport.value.items.map((i) => i.space_name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Total Bookings',
|
||||
data: usageReport.value.items.map((i) => i.total_bookings),
|
||||
backgroundColor: '#4CAF50'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Render users chart (horizontal bar)
|
||||
if (usersChart.value && topUsersReport.value) {
|
||||
if (usersChartInstance) {
|
||||
usersChartInstance.destroy()
|
||||
}
|
||||
|
||||
usersChartInstance = new Chart(usersChart.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: topUsersReport.value.items.map((i) => i.user_name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Total Bookings',
|
||||
data: topUsersReport.value.items.map((i) => i.total_bookings),
|
||||
backgroundColor: '#2196F3'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Render approval chart (pie chart)
|
||||
if (approvalChart.value && approvalReport.value) {
|
||||
if (approvalChartInstance) {
|
||||
approvalChartInstance.destroy()
|
||||
}
|
||||
|
||||
approvalChartInstance = new Chart(approvalChart.value, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: ['Approved', 'Rejected', 'Pending', 'Canceled'],
|
||||
datasets: [
|
||||
{
|
||||
data: [
|
||||
approvalReport.value.approved,
|
||||
approvalReport.value.rejected,
|
||||
approvalReport.value.pending,
|
||||
approvalReport.value.canceled
|
||||
],
|
||||
backgroundColor: ['#4CAF50', '#F44336', '#FFA500', '#9E9E9E']
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, () => {
|
||||
nextTick(() => renderCharts())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadReports()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-reports {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filters label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filters input[type='date'] {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #9e9e9e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #757575;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 10px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #4caf50;
|
||||
border-bottom-color: #4caf50;
|
||||
}
|
||||
|
||||
.report-content {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.report-content h3 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
canvas {
|
||||
max-height: 400px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.report-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.report-table th,
|
||||
.report-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.report-table th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.report-table tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.report-table tfoot {
|
||||
font-weight: bold;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.status-approved {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
.status-rejected {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.status-canceled {
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 2em;
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-card.approved {
|
||||
background: #e8f5e9;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
|
||||
.stat-card.approved h3 {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.stat-card.rejected {
|
||||
background: #ffebee;
|
||||
border-color: #f44336;
|
||||
}
|
||||
|
||||
.stat-card.rejected h3 {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.breakdown {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.breakdown p {
|
||||
margin: 8px 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
</style>
|
||||
290
frontend/src/views/AuditLog.vue
Normal file
290
frontend/src/views/AuditLog.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div class="audit-log">
|
||||
<h2>Jurnal Acțiuni Administrative</h2>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<select v-model="filters.action">
|
||||
<option value="">Toate acțiunile</option>
|
||||
<option value="booking_approved">Rezervare Aprobată</option>
|
||||
<option value="booking_rejected">Rezervare Respinsă</option>
|
||||
<option value="booking_canceled">Rezervare Anulată</option>
|
||||
<option value="space_created">Spațiu Creat</option>
|
||||
<option value="space_updated">Spațiu Actualizat</option>
|
||||
<option value="user_created">Utilizator Creat</option>
|
||||
<option value="user_updated">Utilizator Actualizat</option>
|
||||
<option value="settings_updated">Setări Actualizate</option>
|
||||
</select>
|
||||
|
||||
<input type="date" v-model="filters.start_date" placeholder="Data început" />
|
||||
<input type="date" v-model="filters.end_date" placeholder="Data sfârșit" />
|
||||
|
||||
<button @click="loadLogs">Filtrează</button>
|
||||
<button @click="resetFilters">Resetează</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<p v-if="loading">Se încarcă...</p>
|
||||
|
||||
<!-- Error state -->
|
||||
<p v-else-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<!-- Table -->
|
||||
<table v-else-if="logs.length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Utilizator</th>
|
||||
<th>Acțiune</th>
|
||||
<th>Tip Target</th>
|
||||
<th>ID Target</th>
|
||||
<th>Detalii</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in logs" :key="log.id">
|
||||
<td>{{ formatDate(log.created_at) }}</td>
|
||||
<td>
|
||||
<div>{{ log.user_name }}</div>
|
||||
<small>{{ log.user_email }}</small>
|
||||
</td>
|
||||
<td>{{ formatAction(log.action) }}</td>
|
||||
<td>{{ log.target_type }}</td>
|
||||
<td>{{ log.target_id }}</td>
|
||||
<td>
|
||||
<pre v-if="log.details && Object.keys(log.details).length > 0">{{
|
||||
formatDetails(log.details)
|
||||
}}</pre>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p v-else>Nu există înregistrări în jurnal.</p>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="logs.length > 0">
|
||||
<button @click="prevPage" :disabled="page === 1">Anterior</button>
|
||||
<span>Pagina {{ page }}</span>
|
||||
<button @click="nextPage" :disabled="logs.length < limit">Următor</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { auditLogApi } from '@/services/api'
|
||||
import type { AuditLog } from '@/types'
|
||||
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const page = ref(1)
|
||||
const limit = 50
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const filters = ref({
|
||||
action: '',
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
const loadLogs = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const params: any = { page: page.value, limit }
|
||||
if (filters.value.action) params.action = filters.value.action
|
||||
if (filters.value.start_date) params.start_date = filters.value.start_date
|
||||
if (filters.value.end_date) params.end_date = filters.value.end_date
|
||||
|
||||
logs.value = await auditLogApi.getAll(params)
|
||||
} catch (e) {
|
||||
error.value = 'Eroare la încărcarea jurnalului.'
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value = { action: '', start_date: '', end_date: '' }
|
||||
page.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
const prevPage = () => {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
|
||||
const nextPage = () => {
|
||||
page.value++
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
const formatAction = (action: string) => {
|
||||
const map: Record<string, string> = {
|
||||
booking_approved: 'Rezervare Aprobată',
|
||||
booking_rejected: 'Rezervare Respinsă',
|
||||
booking_canceled: 'Rezervare Anulată',
|
||||
space_created: 'Spațiu Creat',
|
||||
space_updated: 'Spațiu Actualizat',
|
||||
user_created: 'Utilizator Creat',
|
||||
user_updated: 'Utilizator Actualizat',
|
||||
settings_updated: 'Setări Actualizate'
|
||||
}
|
||||
return map[action] || action
|
||||
}
|
||||
|
||||
const formatDetails = (details: any) => {
|
||||
return JSON.stringify(details, null, 2)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-log {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters select,
|
||||
.filters input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filters button {
|
||||
padding: 8px 16px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filters button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.filters button:last-child {
|
||||
background-color: #9e9e9e;
|
||||
}
|
||||
|
||||
.filters button:last-child:hover {
|
||||
background-color: #757575;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f44336;
|
||||
padding: 10px;
|
||||
background-color: #ffebee;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
td small {
|
||||
color: #757575;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 8px 16px;
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background-color: #1976d2;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
background-color: #e0e0e0;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
22
frontend/src/views/Dashboard.vue
Normal file
22
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
157
frontend/src/views/Login.vue
Normal file
157
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card card">
|
||||
<h2>Space Booking</h2>
|
||||
<p class="subtitle">Sign in to your account</p>
|
||||
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Enter your password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" :disabled="loading">
|
||||
{{ loading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="register-link">
|
||||
Don't have an account? <router-link to="/register">Register</router-link>
|
||||
</p>
|
||||
|
||||
<div class="demo-accounts">
|
||||
<p class="demo-title">Demo Accounts:</p>
|
||||
<p><strong>Admin:</strong> admin@example.com / adminpassword</p>
|
||||
<p><strong>User:</strong> user@example.com / userpassword</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { handleApiError } from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await authStore.login({
|
||||
email: email.value,
|
||||
password: password.value
|
||||
})
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.register-link a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.register-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.demo-accounts {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e1e8ed;
|
||||
font-size: 0.9rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.demo-accounts p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #fee;
|
||||
border-left: 3px solid #e74c3c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
682
frontend/src/views/MyBookings.vue
Normal file
682
frontend/src/views/MyBookings.vue
Normal file
@@ -0,0 +1,682 @@
|
||||
<template>
|
||||
<div class="my-bookings">
|
||||
<h2>My Bookings</h2>
|
||||
|
||||
<!-- Filter Card -->
|
||||
<div class="card filter-card">
|
||||
<div class="filter-group">
|
||||
<label for="status-filter">Filter by Status:</label>
|
||||
<select id="status-filter" v-model="selectedStatus" @change="loadBookings">
|
||||
<option value="">All</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="canceled">Canceled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="card">
|
||||
<div class="loading">Loading your bookings...</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="bookings.length === 0" class="card">
|
||||
<div class="empty">
|
||||
You have no bookings yet
|
||||
<router-link to="/spaces" class="btn btn-primary btn-mt">Browse Spaces</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bookings Table (Desktop) -->
|
||||
<div v-else class="card bookings-card">
|
||||
<table class="bookings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Space</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="booking in bookings" :key="booking.id">
|
||||
<td>
|
||||
<div class="space-info">
|
||||
<div class="space-name">{{ booking.space?.name || 'Unknown Space' }}</div>
|
||||
<div class="space-type">{{ formatType(booking.space?.type || '') }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatDate(booking.start_datetime) }}</td>
|
||||
<td>{{ formatTime(booking.start_datetime, booking.end_datetime) }}</td>
|
||||
<td>{{ booking.title }}</td>
|
||||
<td>
|
||||
<span :class="['badge', `badge-${booking.status}`]">
|
||||
{{ formatStatus(booking.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
v-if="booking.status === 'pending'"
|
||||
class="btn btn-sm btn-primary"
|
||||
@click="openEditModal(booking)"
|
||||
style="margin-right: 8px"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
v-if="canCancel(booking)"
|
||||
class="btn btn-sm btn-danger"
|
||||
@click="handleCancel(booking)"
|
||||
:disabled="canceling === booking.id"
|
||||
>
|
||||
{{ canceling === booking.id ? 'Canceling...' : 'Cancel' }}
|
||||
</button>
|
||||
<span v-else-if="booking.status !== 'pending'" class="no-action">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Bookings Cards (Mobile) -->
|
||||
<div class="bookings-cards">
|
||||
<div v-for="booking in bookings" :key="booking.id" class="booking-card">
|
||||
<div class="booking-header">
|
||||
<h3>{{ booking.space?.name || 'Unknown Space' }}</h3>
|
||||
<span :class="['badge', `badge-${booking.status}`]">
|
||||
{{ formatStatus(booking.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="booking-details">
|
||||
<div class="booking-row">
|
||||
<span class="label">Type:</span>
|
||||
<span>{{ formatType(booking.space?.type || '') }}</span>
|
||||
</div>
|
||||
<div class="booking-row">
|
||||
<span class="label">Date:</span>
|
||||
<span>{{ formatDate(booking.start_datetime) }}</span>
|
||||
</div>
|
||||
<div class="booking-row">
|
||||
<span class="label">Time:</span>
|
||||
<span>{{ formatTime(booking.start_datetime, booking.end_datetime) }}</span>
|
||||
</div>
|
||||
<div class="booking-row">
|
||||
<span class="label">Title:</span>
|
||||
<span>{{ booking.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canCancel(booking)" class="booking-actions">
|
||||
<button
|
||||
class="btn btn-danger btn-block"
|
||||
@click="handleCancel(booking)"
|
||||
:disabled="canceling === booking.id"
|
||||
>
|
||||
{{ canceling === booking.id ? 'Canceling...' : 'Cancel Booking' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div v-if="showEditModal" class="modal-overlay" @click.self="closeEditModal">
|
||||
<div class="modal-content">
|
||||
<h3>Edit Booking</h3>
|
||||
<form @submit.prevent="saveEdit">
|
||||
<div class="form-group">
|
||||
<label for="edit-title">Title *</label>
|
||||
<input
|
||||
id="edit-title"
|
||||
v-model="editForm.title"
|
||||
type="text"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="Meeting title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-description">Description</label>
|
||||
<textarea
|
||||
id="edit-description"
|
||||
v-model="editForm.description"
|
||||
rows="3"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-start">Start Date/Time *</label>
|
||||
<input
|
||||
id="edit-start"
|
||||
v-model="editForm.start_datetime"
|
||||
type="datetime-local"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-end">End Date/Time *</label>
|
||||
<input
|
||||
id="edit-end"
|
||||
v-model="editForm.end_datetime"
|
||||
type="datetime-local"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="editError" class="error-message">{{ editError }}</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="closeEditModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="card">
|
||||
<div class="error">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 type { Booking } from '@/types'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
||||
|
||||
const bookings = ref<Booking[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const selectedStatus = ref('')
|
||||
const canceling = ref<number | null>(null)
|
||||
|
||||
// Edit modal state
|
||||
const showEditModal = ref(false)
|
||||
const editingBooking = ref<Booking | null>(null)
|
||||
const editForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
start_datetime: '',
|
||||
end_datetime: ''
|
||||
})
|
||||
const editError = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const loadBookings = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
bookings.value = await bookingsApi.getMy(selectedStatus.value || undefined)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (datetime: string): string => {
|
||||
return formatDateTZ(datetime, userTimezone.value)
|
||||
}
|
||||
|
||||
const formatTime = (start: string, end: string): string => {
|
||||
const startTime = formatTimeTZ(start, userTimezone.value)
|
||||
const endTime = formatTimeTZ(end, userTimezone.value)
|
||||
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',
|
||||
birou: 'Birou'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
const formatStatus = (status: string): string => {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
editForm.value = {
|
||||
title: booking.title,
|
||||
description: booking.description || '',
|
||||
start_datetime: start.toISOString().slice(0, 16),
|
||||
end_datetime: end.toISOString().slice(0, 16)
|
||||
}
|
||||
editError.value = ''
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingBooking.value = null
|
||||
editError.value = ''
|
||||
}
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingBooking.value) return
|
||||
|
||||
saving.value = true
|
||||
editError.value = ''
|
||||
|
||||
try {
|
||||
// Convert datetime-local format back to ISO
|
||||
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()
|
||||
}
|
||||
|
||||
await bookingsApi.update(editingBooking.value.id, updateData)
|
||||
closeEditModal()
|
||||
await loadBookings()
|
||||
} catch (err) {
|
||||
editError.value = handleApiError(err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const canCancel = (booking: Booking): boolean => {
|
||||
// Can only cancel pending or approved bookings
|
||||
return booking.status === 'pending' || booking.status === 'approved'
|
||||
}
|
||||
|
||||
const handleCancel = async (booking: Booking) => {
|
||||
if (!confirm(`Are you sure you want to cancel the booking "${booking.title}"?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
canceling.value = booking.id
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// TODO: Implement cancel endpoint when available
|
||||
// await bookingsApi.cancel(booking.id)
|
||||
// For now, just show a message
|
||||
alert('Cancel functionality will be implemented in a future update.')
|
||||
await loadBookings()
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
canceling.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBookings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.my-bookings {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: white;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filter-group select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bookings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
display: table;
|
||||
}
|
||||
|
||||
.bookings-table th {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.bookings-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.bookings-table tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.space-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.space-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.space-type {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.badge-approved {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-rejected {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.badge-canceled {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.no-action {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-mt {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Edit Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin: 0 0 20px 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
/* Mobile Cards */
|
||||
.bookings-cards {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bookings-table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bookings-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.booking-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.booking-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.booking-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.booking-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.booking-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.booking-row .label {
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.booking-actions {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
201
frontend/src/views/Register.vue
Normal file
201
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="register-container">
|
||||
<div class="register-card card">
|
||||
<h2>Create Account</h2>
|
||||
<p class="subtitle">Sign up for Space Booking</p>
|
||||
|
||||
<form @submit.prevent="handleRegister">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Enter password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<small class="help-text"
|
||||
>At least 8 characters, with uppercase, lowercase, and digit</small
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Confirm Password</label>
|
||||
<input
|
||||
id="confirm_password"
|
||||
v-model="form.confirm_password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Confirm password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="full_name">Full Name</label>
|
||||
<input
|
||||
id="full_name"
|
||||
v-model="form.full_name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="John Doe"
|
||||
autocomplete="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="organization">Organization</label>
|
||||
<input
|
||||
id="organization"
|
||||
v-model="form.organization"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Your Organization"
|
||||
autocomplete="organization"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="successMessage" class="success">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block" :disabled="loading">
|
||||
{{ loading ? 'Creating account...' : 'Register' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login-link">
|
||||
Already have an account? <router-link to="/login">Login</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi, handleApiError } from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = ref({
|
||||
email: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
full_name: '',
|
||||
organization: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
const response = await authApi.register(form.value)
|
||||
successMessage.value = response.message
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 3000)
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = handleApiError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.login-link a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #fee;
|
||||
border-left: 3px solid #e74c3c;
|
||||
border-radius: 4px;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.success {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #d4edda;
|
||||
border-left: 3px solid #28a745;
|
||||
border-radius: 4px;
|
||||
color: #155724;
|
||||
}
|
||||
</style>
|
||||
373
frontend/src/views/Settings.vue
Normal file
373
frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="settings">
|
||||
<h2>Global Booking Settings</h2>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<div class="card">
|
||||
<h3>Booking Rules Configuration</h3>
|
||||
|
||||
<div v-if="loadingSettings" class="loading">Loading settings...</div>
|
||||
|
||||
<form v-else @submit.prevent="handleSubmit" class="settings-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="minDuration">Minimum Duration (minutes)</label>
|
||||
<input
|
||||
id="minDuration"
|
||||
v-model.number="formData.min_duration_minutes"
|
||||
type="number"
|
||||
required
|
||||
min="15"
|
||||
max="480"
|
||||
placeholder="30"
|
||||
/>
|
||||
<small>Between 15 and 480 minutes</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="maxDuration">Maximum Duration (minutes)</label>
|
||||
<input
|
||||
id="maxDuration"
|
||||
v-model.number="formData.max_duration_minutes"
|
||||
type="number"
|
||||
required
|
||||
min="30"
|
||||
max="1440"
|
||||
placeholder="480"
|
||||
/>
|
||||
<small>Between 30 and 1440 minutes (24h)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="workStart">Working Hours Start (hour)</label>
|
||||
<input
|
||||
id="workStart"
|
||||
v-model.number="formData.working_hours_start"
|
||||
type="number"
|
||||
required
|
||||
min="0"
|
||||
max="23"
|
||||
placeholder="8"
|
||||
/>
|
||||
<small>0-23 (e.g., 8 = 8:00 AM)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="workEnd">Working Hours End (hour)</label>
|
||||
<input
|
||||
id="workEnd"
|
||||
v-model.number="formData.working_hours_end"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="24"
|
||||
placeholder="20"
|
||||
/>
|
||||
<small>1-24 (e.g., 20 = 8:00 PM)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="maxBookings">Max Bookings per Day per User</label>
|
||||
<input
|
||||
id="maxBookings"
|
||||
v-model.number="formData.max_bookings_per_day_per_user"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="20"
|
||||
placeholder="3"
|
||||
/>
|
||||
<small>Between 1 and 20</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="minCancel">Min Hours Before Cancel</label>
|
||||
<input
|
||||
id="minCancel"
|
||||
v-model.number="formData.min_hours_before_cancel"
|
||||
type="number"
|
||||
required
|
||||
min="0"
|
||||
max="72"
|
||||
placeholder="2"
|
||||
/>
|
||||
<small>Between 0 and 72 hours</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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 These Settings</h4>
|
||||
<ul>
|
||||
<li><strong>Duration:</strong> Controls minimum and maximum booking length</li>
|
||||
<li><strong>Working Hours:</strong> Bookings outside these hours will be rejected</li>
|
||||
<li><strong>Max Bookings:</strong> Limits how many bookings a user can make per day</li>
|
||||
<li><strong>Cancel Policy:</strong> Users cannot cancel bookings too close to start time</li>
|
||||
</ul>
|
||||
<p class="note">These rules apply to all new booking requests.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, handleApiError } from '@/services/api'
|
||||
import type { Settings } from '@/types'
|
||||
|
||||
const loadingSettings = ref(true)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const formData = ref<Omit<Settings, 'id'>>({
|
||||
min_duration_minutes: 30,
|
||||
max_duration_minutes: 480,
|
||||
working_hours_start: 8,
|
||||
working_hours_end: 20,
|
||||
max_bookings_per_day_per_user: 3,
|
||||
min_hours_before_cancel: 2
|
||||
})
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
loadingSettings.value = true
|
||||
const settings = await settingsApi.get()
|
||||
formData.value = {
|
||||
min_duration_minutes: settings.min_duration_minutes,
|
||||
max_duration_minutes: settings.max_duration_minutes,
|
||||
working_hours_start: settings.working_hours_start,
|
||||
working_hours_end: settings.working_hours_end,
|
||||
max_bookings_per_day_per_user: settings.max_bookings_per_day_per_user,
|
||||
min_hours_before_cancel: settings.min_hours_before_cancel
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loadingSettings.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
// Validate all fields are positive
|
||||
if (
|
||||
formData.value.min_duration_minutes <= 0 ||
|
||||
formData.value.max_duration_minutes <= 0 ||
|
||||
formData.value.working_hours_start < 0 ||
|
||||
formData.value.working_hours_end < 0 ||
|
||||
formData.value.max_bookings_per_day_per_user <= 0 ||
|
||||
formData.value.min_hours_before_cancel < 0
|
||||
) {
|
||||
error.value = 'All fields must be positive values'
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate min < max duration
|
||||
if (formData.value.min_duration_minutes >= formData.value.max_duration_minutes) {
|
||||
error.value = 'Minimum duration must be less than maximum duration'
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate working hours start < end
|
||||
if (formData.value.working_hours_start >= formData.value.working_hours_end) {
|
||||
error.value = 'Working hours start must be less than working hours end'
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate working hours are within 0-23 for start and 1-24 for end
|
||||
if (formData.value.working_hours_start < 0 || formData.value.working_hours_start > 23) {
|
||||
error.value = 'Working hours start must be between 0 and 23'
|
||||
return false
|
||||
}
|
||||
|
||||
if (formData.value.working_hours_end < 1 || formData.value.working_hours_end > 24) {
|
||||
error.value = 'Working hours end must be between 1 and 24'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
// Client-side validation
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await settingsApi.update(formData.value)
|
||||
success.value = 'Settings updated successfully!'
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings {
|
||||
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;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
388
frontend/src/views/SpaceDetail.vue
Normal file
388
frontend/src/views/SpaceDetail.vue
Normal file
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<div class="space-detail">
|
||||
<!-- Breadcrumbs -->
|
||||
<nav class="breadcrumbs">
|
||||
<router-link to="/">Home</router-link>
|
||||
<span class="separator">/</span>
|
||||
<router-link to="/spaces">Spaces</router-link>
|
||||
<span class="separator">/</span>
|
||||
<span class="current">{{ space?.name || 'Loading...' }}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading space details...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="error-container">
|
||||
<div class="error-card">
|
||||
<h3>Error Loading Space</h3>
|
||||
<p>{{ error }}</p>
|
||||
<router-link to="/spaces" class="btn btn-primary">Back to Spaces</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Space Details -->
|
||||
<div v-else-if="space" class="space-content">
|
||||
<!-- Header Section -->
|
||||
<div class="space-header">
|
||||
<div class="header-info">
|
||||
<h1>{{ space.name }}</h1>
|
||||
<div class="space-meta">
|
||||
<span class="badge badge-type">{{ formatType(space.type) }}</span>
|
||||
<span class="badge badge-capacity">
|
||||
<svg class="icon" 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>
|
||||
Capacity: {{ space.capacity }}
|
||||
</span>
|
||||
<span :class="['badge', space.is_active ? 'badge-active' : 'badge-inactive']">
|
||||
{{ space.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-reserve"
|
||||
:disabled="!space.is_active"
|
||||
@click="handleReserve"
|
||||
>
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
Reserve Space
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="space.description" class="card description-card">
|
||||
<h3>Description</h3>
|
||||
<p>{{ space.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Section -->
|
||||
<div class="card calendar-card">
|
||||
<h3>Availability Calendar</h3>
|
||||
<p class="calendar-subtitle">View existing bookings and available time slots</p>
|
||||
<SpaceCalendar :space-id="space.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { spacesApi, handleApiError } from '@/services/api'
|
||||
import SpaceCalendar from '@/components/SpaceCalendar.vue'
|
||||
import type { Space } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const space = ref<Space | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
// Format space type for display
|
||||
const formatType = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
sala: 'Sala',
|
||||
birou: 'Birou'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
// Load space details
|
||||
const loadSpace = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const spaceId = Number(route.params.id)
|
||||
|
||||
if (isNaN(spaceId)) {
|
||||
error.value = 'Invalid space ID'
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch all spaces and filter by ID
|
||||
// (Could be optimized with a dedicated GET /api/spaces/:id endpoint in the future)
|
||||
const spaces = await spacesApi.list()
|
||||
const foundSpace = spaces.find((s) => s.id === spaceId)
|
||||
|
||||
if (!foundSpace) {
|
||||
error.value = 'Space not found (404). The space may not exist or has been removed.'
|
||||
} else {
|
||||
space.value = foundSpace
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reserve button click
|
||||
const handleReserve = () => {
|
||||
// Placeholder for US-004d: Redirect to booking creation page
|
||||
// For now, navigate to a placeholder route
|
||||
router.push({
|
||||
path: '/booking/new',
|
||||
query: { space: space.value?.id }
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSpace()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.space-detail {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Breadcrumbs */
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.breadcrumbs a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumbs a:hover {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumbs .separator {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.breadcrumbs .current {
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.error-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.error-card h3 {
|
||||
color: #991b1b;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-card p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Space Content */
|
||||
.space-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Header Section */
|
||||
.space-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.header-info h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.space-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-type {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-capacity {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Reserve Button */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-reserve {
|
||||
min-width: 180px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Description Card */
|
||||
.description-card p {
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Calendar Card */
|
||||
.calendar-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.space-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-reserve {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-info h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
11
frontend/src/views/Spaces.vue
Normal file
11
frontend/src/views/Spaces.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="spaces">
|
||||
<h2>Spaces</h2>
|
||||
<div class="card">
|
||||
<p>Spaces list coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
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>
|
||||
600
frontend/src/views/Users.vue
Normal file
600
frontend/src/views/Users.vue
Normal file
@@ -0,0 +1,600 @@
|
||||
<template>
|
||||
<div class="users">
|
||||
<h2>Admin Dashboard - User Management</h2>
|
||||
|
||||
<!-- Create/Edit Form -->
|
||||
<div class="card">
|
||||
<h3>{{ editingUser ? 'Edit User' : 'Create New User' }}</h3>
|
||||
<form @submit.prevent="handleSubmit" class="user-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email *</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="user@example.com"
|
||||
:disabled="!!editingUser"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="full_name">Full Name *</label>
|
||||
<input
|
||||
id="full_name"
|
||||
v-model="formData.full_name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="!editingUser">
|
||||
<label for="password">Password *</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
:required="!editingUser"
|
||||
placeholder="Minimum 8 characters"
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role">Role *</label>
|
||||
<select id="role" v-model="formData.role" required>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="organization">Organization</label>
|
||||
<input
|
||||
id="organization"
|
||||
v-model="formData.organization"
|
||||
type="text"
|
||||
placeholder="Optional organization"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ editingUser ? 'Update' : 'Create' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editingUser"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="success" class="success">{{ success }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card">
|
||||
<h3>Filters</h3>
|
||||
<div class="filters">
|
||||
<div class="form-group">
|
||||
<label for="filter-role">Filter by Role</label>
|
||||
<select id="filter-role" v-model="filterRole" @change="loadUsers">
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="filter-org">Filter by Organization</label>
|
||||
<input
|
||||
id="filter-org"
|
||||
v-model="filterOrganization"
|
||||
type="text"
|
||||
placeholder="Enter organization name"
|
||||
@input="loadUsers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users List -->
|
||||
<div class="card">
|
||||
<h3>All Users</h3>
|
||||
<div v-if="loadingUsers" class="loading">Loading users...</div>
|
||||
<div v-else-if="users.length === 0" class="empty">
|
||||
No users found. {{ filterRole || filterOrganization ? 'Try different filters.' : 'Create one above!' }}
|
||||
</div>
|
||||
<table v-else class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Full Name</th>
|
||||
<th>Role</th>
|
||||
<th>Organization</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{{ user.full_name }}</td>
|
||||
<td>
|
||||
<span :class="['badge', user.role === 'admin' ? 'badge-admin' : 'badge-user']">
|
||||
{{ user.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user.organization || '-' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', user.is_active ? 'badge-active' : 'badge-inactive']">
|
||||
{{ user.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
@click="startEdit(user)"
|
||||
:disabled="loading"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
:class="['btn', 'btn-sm', user.is_active ? 'btn-warning' : 'btn-success']"
|
||||
@click="toggleStatus(user)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ user.is_active ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
@click="showResetPassword(user)"
|
||||
:disabled="loading"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Reset Password Modal -->
|
||||
<div v-if="resetPasswordUser" class="modal" @click.self="closeResetPassword">
|
||||
<div class="modal-content">
|
||||
<h3>Reset Password for {{ resetPasswordUser.full_name }}</h3>
|
||||
<form @submit.prevent="handleResetPassword">
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password *</label>
|
||||
<input
|
||||
id="new_password"
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Minimum 8 characters"
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
Reset Password
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" @click="closeResetPassword">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { usersApi, handleApiError } from '@/services/api'
|
||||
import type { User } from '@/types'
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const loadingUsers = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const editingUser = ref<User | null>(null)
|
||||
const resetPasswordUser = ref<User | null>(null)
|
||||
const newPassword = ref('')
|
||||
const filterRole = ref('')
|
||||
const filterOrganization = ref('')
|
||||
|
||||
const formData = ref({
|
||||
email: '',
|
||||
full_name: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
organization: ''
|
||||
})
|
||||
|
||||
const loadUsers = async () => {
|
||||
loadingUsers.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params: { role?: string; organization?: string } = {}
|
||||
if (filterRole.value) params.role = filterRole.value
|
||||
if (filterOrganization.value) params.organization = filterOrganization.value
|
||||
|
||||
users.value = await usersApi.list(params)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
if (editingUser.value) {
|
||||
await usersApi.update(editingUser.value.id, {
|
||||
full_name: formData.value.full_name,
|
||||
role: formData.value.role,
|
||||
organization: formData.value.organization || undefined
|
||||
})
|
||||
success.value = 'User updated successfully!'
|
||||
} else {
|
||||
await usersApi.create({
|
||||
email: formData.value.email,
|
||||
full_name: formData.value.full_name,
|
||||
password: formData.value.password,
|
||||
role: formData.value.role,
|
||||
organization: formData.value.organization || undefined
|
||||
})
|
||||
success.value = 'User created successfully!'
|
||||
}
|
||||
|
||||
resetForm()
|
||||
await loadUsers()
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (user: User) => {
|
||||
editingUser.value = user
|
||||
formData.value = {
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
password: '',
|
||||
role: user.role,
|
||||
organization: user.organization || ''
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
editingUser.value = null
|
||||
formData.value = {
|
||||
email: '',
|
||||
full_name: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
organization: ''
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = async (user: User) => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
await usersApi.updateStatus(user.id, !user.is_active)
|
||||
success.value = `User ${user.is_active ? 'deactivated' : 'activated'} successfully!`
|
||||
await loadUsers()
|
||||
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showResetPassword = (user: User) => {
|
||||
resetPasswordUser.value = user
|
||||
newPassword.value = ''
|
||||
}
|
||||
|
||||
const closeResetPassword = () => {
|
||||
resetPasswordUser.value = null
|
||||
newPassword.value = ''
|
||||
}
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!resetPasswordUser.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
await usersApi.resetPassword(resetPasswordUser.value.id, newPassword.value)
|
||||
success.value = 'Password reset successfully!'
|
||||
closeResetPassword()
|
||||
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.users {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.user-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: #f3f4f6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 12px;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border-radius: 4px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.badge-user {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
242
frontend/src/views/VerifyEmail.vue
Normal file
242
frontend/src/views/VerifyEmail.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="verify-container">
|
||||
<div class="verify-card card">
|
||||
<div v-if="verifying" class="verifying-state">
|
||||
<div class="spinner"></div>
|
||||
<h2>Verifying your email...</h2>
|
||||
<p>Please wait...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="success" class="success-state">
|
||||
<div class="icon-success">✓</div>
|
||||
<h2>Email Verified!</h2>
|
||||
<p>{{ message }}</p>
|
||||
<p class="login-link">
|
||||
You can now <router-link to="/login">log in</router-link> to your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="error-state">
|
||||
<div class="icon-error">✗</div>
|
||||
<h2>Verification Failed</h2>
|
||||
<p class="error-message">{{ errorMessage }}</p>
|
||||
|
||||
<div v-if="showResendOption" class="resend-section">
|
||||
<p>Need a new verification link?</p>
|
||||
<div class="resend-form">
|
||||
<input
|
||||
v-model="resendEmail"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
class="resend-input"
|
||||
/>
|
||||
<button @click="resendVerification" class="btn btn-primary" :disabled="resending">
|
||||
{{ resending ? 'Sending...' : 'Resend' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="resendMessage" class="resend-message">{{ resendMessage }}</div>
|
||||
</div>
|
||||
|
||||
<p class="login-link">
|
||||
Go back to <router-link to="/login">login</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { authApi, handleApiError } from '@/services/api'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const verifying = ref(true)
|
||||
const success = ref(false)
|
||||
const message = ref('')
|
||||
const errorMessage = ref('')
|
||||
const showResendOption = ref(false)
|
||||
const resendEmail = ref('')
|
||||
const resending = ref(false)
|
||||
const resendMessage = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const token = route.query.token as string
|
||||
|
||||
if (!token) {
|
||||
verifying.value = false
|
||||
errorMessage.value = 'No verification token provided'
|
||||
showResendOption.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authApi.verifyEmail({ token })
|
||||
success.value = true
|
||||
message.value = response.message
|
||||
} catch (error: unknown) {
|
||||
success.value = false
|
||||
errorMessage.value = handleApiError(error)
|
||||
|
||||
// Show resend option for expired tokens
|
||||
if (errorMessage.value.toLowerCase().includes('expired')) {
|
||||
showResendOption.value = true
|
||||
}
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const resendVerification = async () => {
|
||||
if (!resendEmail.value) {
|
||||
resendMessage.value = 'Please enter your email address'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
resending.value = true
|
||||
resendMessage.value = ''
|
||||
const response = await authApi.resendVerification(resendEmail.value)
|
||||
resendMessage.value = response.message
|
||||
} catch (error: unknown) {
|
||||
resendMessage.value = handleApiError(error)
|
||||
} finally {
|
||||
resending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.verify-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.verify-card {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.verifying-state {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.success-state,
|
||||
.error-state {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
font-size: 3rem;
|
||||
line-height: 80px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.icon-error {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
font-size: 3rem;
|
||||
line-height: 80px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #c0392b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.login-link a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.resend-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #e1e8ed;
|
||||
}
|
||||
|
||||
.resend-section > p {
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.resend-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.resend-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.resend-input:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.resend-message {
|
||||
padding: 0.75rem;
|
||||
background: #d4edda;
|
||||
border-left: 3px solid #28a745;
|
||||
border-radius: 4px;
|
||||
color: #155724;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user