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