feat: Space Booking System - MVP complet

Sistem web pentru rezervarea de birouri și săli de ședință
cu flux de aprobare administrativă.

Stack: FastAPI + Vue.js 3 + SQLite + TypeScript

Features implementate:
- Autentificare JWT + Self-registration cu email verification
- CRUD Spații, Utilizatori, Settings (Admin)
- Calendar interactiv (FullCalendar) cu drag-and-drop
- Creare rezervări cu validare (durată, program, overlap, max/zi)
- Rezervări recurente (săptămânal)
- Admin: aprobare/respingere/anulare cereri
- Admin: creare directă rezervări (bypass approval)
- Admin: editare orice rezervare
- User: editare/anulare rezervări proprii
- Notificări in-app (bell icon + dropdown)
- Notificări email (async SMTP cu BackgroundTasks)
- Jurnal acțiuni administrative (audit log)
- Rapoarte avansate (utilizare, top users, approval rate)
- Șabloane rezervări (booking templates)
- Atașamente fișiere (upload/download)
- Conflict warnings (verificare disponibilitate real-time)
- Integrare Google Calendar (OAuth2)
- Suport timezone (UTC storage + user preference)
- 225+ teste backend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-09 17:51:29 +00:00
commit df4031d99c
113 changed files with 24491 additions and 0 deletions

View File

@@ -0,0 +1,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>

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,527 @@
<template>
<div class="user-profile">
<h2>User Profile</h2>
<!-- Profile Information Card -->
<div class="card">
<h3>Profile Information</h3>
<div v-if="user" class="profile-info">
<div class="info-item">
<label>Email:</label>
<span>{{ user.email }}</span>
</div>
<div class="info-item">
<label>Full Name:</label>
<span>{{ user.full_name }}</span>
</div>
<div class="info-item">
<label>Organization:</label>
<span>{{ user.organization || 'N/A' }}</span>
</div>
<div class="info-item">
<label>Role:</label>
<span>{{ user.role }}</span>
</div>
</div>
</div>
<!-- Timezone Preferences Card -->
<div class="card">
<h3>Timezone Preferences</h3>
<div v-if="loadingTimezones" class="loading">Loading timezones...</div>
<div v-else class="timezone-settings">
<p class="info-text">
Select your timezone to see all booking times displayed in your local time.
</p>
<div class="form-group">
<label for="timezone">Timezone:</label>
<select
id="timezone"
v-model="selectedTimezone"
@change="updateTimezone"
class="timezone-select"
:disabled="updatingTimezone"
>
<option v-for="tz in availableTimezones" :key="tz" :value="tz">
{{ tz }}
</option>
</select>
</div>
<p class="help-text">All times will be displayed in your timezone throughout the app.</p>
<div v-if="timezoneSuccess" class="success">{{ timezoneSuccess }}</div>
<div v-if="timezoneError" class="error">{{ timezoneError }}</div>
</div>
</div>
<!-- Google Calendar Integration Card -->
<div class="card">
<h3>Google Calendar Integration</h3>
<div v-if="loadingGoogleStatus" class="loading">Checking connection status...</div>
<div v-else>
<div v-if="googleStatus.connected" class="google-connected">
<div class="status-indicator">
<span class="status-icon"></span>
<span>Connected to Google Calendar</span>
</div>
<p v-if="googleStatus.expires_at" class="expiry-info">
Token expires: {{ formatDate(googleStatus.expires_at) }}
</p>
<p class="info-text">
Your approved bookings will automatically sync to your Google Calendar.
</p>
<button @click="disconnectGoogle" class="btn btn-danger" :disabled="disconnecting">
{{ disconnecting ? 'Disconnecting...' : 'Disconnect Google Calendar' }}
</button>
</div>
<div v-else class="google-disconnected">
<p class="info-text">
Connect your Google Calendar to automatically sync approved bookings.
</p>
<ul class="benefits-list">
<li>Approved bookings are automatically added to your calendar</li>
<li>Canceled bookings are automatically removed</li>
<li>Stay organized with automatic calendar updates</li>
</ul>
<button @click="connectGoogle" class="btn btn-primary" :disabled="connecting">
{{ connecting ? 'Connecting...' : 'Connect Google Calendar' }}
</button>
</div>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div>
</div>
<!-- Info Card -->
<div class="card info-card">
<h4>About Calendar Integration</h4>
<ul>
<li>
<strong>Automatic Sync:</strong> When your booking is approved, it's automatically added to
your Google Calendar
</li>
<li>
<strong>Updates:</strong> Canceled bookings are automatically removed from your calendar
</li>
<li><strong>Privacy:</strong> Only your bookings are synced, not other users' bookings</li>
<li>
<strong>Security:</strong> You can disconnect at any time by clicking the disconnect button
above
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { usersApi, googleCalendarApi, handleApiError } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import type { User } from '@/types'
const authStore = useAuthStore()
const user = ref<User | null>(null)
const loadingGoogleStatus = ref(true)
const connecting = ref(false)
const disconnecting = ref(false)
const error = ref('')
const success = ref('')
const googleStatus = ref<{ connected: boolean; expires_at: string | null }>({
connected: false,
expires_at: null
})
// Timezone state
const availableTimezones = ref<string[]>([])
const selectedTimezone = ref<string>('UTC')
const loadingTimezones = ref(true)
const updatingTimezone = ref(false)
const timezoneSuccess = ref('')
const timezoneError = ref('')
const loadUser = async () => {
try {
user.value = await usersApi.me()
selectedTimezone.value = user.value?.timezone || 'UTC'
} catch (err) {
error.value = handleApiError(err)
}
}
const loadTimezones = async () => {
try {
loadingTimezones.value = true
availableTimezones.value = await usersApi.getTimezones()
} catch (err) {
timezoneError.value = handleApiError(err)
} finally {
loadingTimezones.value = false
}
}
const updateTimezone = async () => {
timezoneError.value = ''
timezoneSuccess.value = ''
updatingTimezone.value = true
try {
await usersApi.updateTimezone(selectedTimezone.value)
// Update auth store
if (authStore.user) {
authStore.user.timezone = selectedTimezone.value
}
timezoneSuccess.value = 'Timezone updated successfully! Refresh the page to see times in your timezone.'
setTimeout(() => {
timezoneSuccess.value = ''
}, 5000)
} catch (err) {
timezoneError.value = handleApiError(err)
// Revert selection on error
if (user.value) {
selectedTimezone.value = user.value.timezone
}
} finally {
updatingTimezone.value = false
}
}
const checkGoogleStatus = async () => {
try {
loadingGoogleStatus.value = true
googleStatus.value = await googleCalendarApi.status()
} catch (err) {
error.value = handleApiError(err)
} finally {
loadingGoogleStatus.value = false
}
}
const connectGoogle = async () => {
error.value = ''
success.value = ''
connecting.value = true
try {
const response = await googleCalendarApi.connect()
// Open OAuth URL in popup window
const popup = window.open(
response.authorization_url,
'Google Calendar Authorization',
'width=600,height=600,toolbar=no,menubar=no,location=no'
)
// Poll for connection status
const pollInterval = setInterval(async () => {
// Check if popup was closed
if (popup && popup.closed) {
clearInterval(pollInterval)
connecting.value = false
// Check status one more time
await checkGoogleStatus()
if (googleStatus.value.connected) {
success.value = 'Google Calendar connected successfully!'
setTimeout(() => {
success.value = ''
}, 3000)
}
} else {
// Poll for connection status
try {
const status = await googleCalendarApi.status()
if (status.connected) {
clearInterval(pollInterval)
connecting.value = false
googleStatus.value = status
success.value = 'Google Calendar connected successfully!'
// Close popup
if (popup) {
popup.close()
}
setTimeout(() => {
success.value = ''
}, 3000)
}
} catch (err) {
// Ignore polling errors
}
}
}, 2000)
// Stop polling after 5 minutes
setTimeout(() => {
clearInterval(pollInterval)
connecting.value = false
if (popup && !popup.closed) {
popup.close()
}
}, 300000)
} catch (err) {
error.value = handleApiError(err)
connecting.value = false
}
}
const disconnectGoogle = async () => {
if (!confirm('Are you sure you want to disconnect Google Calendar?')) {
return
}
error.value = ''
success.value = ''
disconnecting.value = true
try {
await googleCalendarApi.disconnect()
googleStatus.value = { connected: false, expires_at: null }
success.value = 'Google Calendar disconnected successfully!'
setTimeout(() => {
success.value = ''
}, 3000)
} catch (err) {
error.value = handleApiError(err)
} finally {
disconnecting.value = false
}
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleString()
}
onMounted(() => {
loadUser()
loadTimezones()
checkGoogleStatus()
})
</script>
<style scoped>
.user-profile {
max-width: 900px;
margin: 0 auto;
}
h2 {
margin-bottom: 1.5rem;
color: #333;
}
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h3,
h4 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #444;
}
.profile-info {
display: flex;
flex-direction: column;
gap: 1rem;
}
.info-item {
display: flex;
gap: 1rem;
}
.info-item label {
font-weight: 600;
min-width: 120px;
color: #555;
}
.info-item span {
color: #333;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.google-connected,
.google-disconnected {
display: flex;
flex-direction: column;
gap: 1rem;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.1rem;
font-weight: 500;
color: #4caf50;
}
.status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background-color: #4caf50;
color: white;
border-radius: 50%;
font-size: 0.9rem;
}
.expiry-info {
color: #666;
font-size: 0.9rem;
margin: 0;
}
.info-text {
color: #555;
line-height: 1.6;
margin: 0;
}
.benefits-list {
margin: 0;
padding-left: 1.5rem;
}
.benefits-list li {
margin-bottom: 0.5rem;
color: #555;
}
.btn {
padding: 0.6rem 1.2rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
align-self: flex-start;
}
.btn-primary {
background-color: #4285f4;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #357ae8;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover:not(:disabled) {
background-color: #c82333;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
padding: 0.75rem;
background-color: #fee;
border: 1px solid #fcc;
border-radius: 4px;
color: #c33;
margin-top: 1rem;
}
.success {
padding: 0.75rem;
background-color: #efe;
border: 1px solid #cfc;
border-radius: 4px;
color: #3c3;
margin-top: 1rem;
}
.info-card {
background-color: #f8f9fa;
}
.info-card ul {
margin: 0;
padding-left: 1.5rem;
}
.info-card li {
margin-bottom: 0.5rem;
color: #555;
}
.timezone-settings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: #555;
}
.timezone-select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
max-width: 400px;
cursor: pointer;
}
.timezone-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.help-text {
color: #666;
font-size: 0.9rem;
font-style: italic;
margin: 0;
}
</style>

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

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