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:
237
frontend/src/components/AttachmentsList.vue
Normal file
237
frontend/src/components/AttachmentsList.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="attachments-list">
|
||||
<h4 class="attachments-title">Attachments</h4>
|
||||
|
||||
<div v-if="loading" class="loading">Loading attachments...</div>
|
||||
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="attachments.length === 0" class="no-attachments">No attachments</div>
|
||||
|
||||
<ul v-else class="attachment-items">
|
||||
<li v-for="attachment in attachments" :key="attachment.id" class="attachment-item">
|
||||
<div class="attachment-info">
|
||||
<span class="attachment-icon">📎</span>
|
||||
<div class="attachment-details">
|
||||
<a
|
||||
:href="getDownloadUrl(attachment.id)"
|
||||
class="attachment-name"
|
||||
target="_blank"
|
||||
:download="attachment.filename"
|
||||
>
|
||||
{{ attachment.filename }}
|
||||
</a>
|
||||
<span class="attachment-meta">
|
||||
{{ formatFileSize(attachment.size) }} · Uploaded by {{ attachment.uploader_name }} ·
|
||||
{{ formatDate(attachment.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
:disabled="deleting === attachment.id"
|
||||
@click="handleDelete(attachment.id)"
|
||||
>
|
||||
{{ deleting === attachment.id ? 'Deleting...' : 'Delete' }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { attachmentsApi, handleApiError } from '@/services/api'
|
||||
import type { Attachment } from '@/types'
|
||||
|
||||
interface Props {
|
||||
bookingId: number
|
||||
canDelete?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'deleted'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const attachments = ref<Attachment[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const deleting = ref<number | null>(null)
|
||||
|
||||
const loadAttachments = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
attachments.value = await attachmentsApi.list(props.bookingId)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (attachmentId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this attachment?')) {
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = attachmentId
|
||||
try {
|
||||
await attachmentsApi.delete(attachmentId)
|
||||
attachments.value = attachments.value.filter(a => a.id !== attachmentId)
|
||||
emit('deleted')
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
deleting.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const getDownloadUrl = (attachmentId: number): string => {
|
||||
return attachmentsApi.download(attachmentId)
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAttachments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attachments-list {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.attachments-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.no-attachments {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.no-attachments {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.attachment-items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.attachment-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachment-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
font-size: 14px;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.attachment-meta {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
padding: 6px 12px;
|
||||
background: white;
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.btn-delete:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.attachment-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user