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>
|
||||
1308
frontend/src/components/BookingForm.vue
Normal file
1308
frontend/src/components/BookingForm.vue
Normal file
File diff suppressed because it is too large
Load Diff
43
frontend/src/components/README.md
Normal file
43
frontend/src/components/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# SpaceCalendar Component
|
||||
|
||||
Component Vue.js 3 pentru afișarea rezervărilor unui spațiu folosind FullCalendar.
|
||||
|
||||
## Utilizare
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<h2>Rezervări pentru Sala A</h2>
|
||||
<SpaceCalendar :space-id="1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SpaceCalendar from '@/components/SpaceCalendar.vue'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `spaceId` | `number` | Yes | ID-ul spațiului pentru care se afișează rezervările |
|
||||
|
||||
## Features
|
||||
|
||||
- **View Switcher**: Month, Week, Day views
|
||||
- **Status Colors**:
|
||||
- Pending: Orange (#FFA500)
|
||||
- Approved: Green (#4CAF50)
|
||||
- Rejected: Red (#F44336)
|
||||
- Canceled: Gray (#9E9E9E)
|
||||
- **Auto-refresh**: Se încarcă automat rezervările când se schimbă data
|
||||
- **Responsive**: Se adaptează la dimensiunea ecranului
|
||||
|
||||
## API Integration
|
||||
|
||||
Componenta folosește `bookingsApi.getForSpace(spaceId, start, end)` pentru a încărca rezervările.
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
Componenta este complet type-safe și folosește interfețele din `/src/types/index.ts`.
|
||||
484
frontend/src/components/SpaceCalendar.vue
Normal file
484
frontend/src/components/SpaceCalendar.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<template>
|
||||
<div class="space-calendar">
|
||||
<div v-if="isEditable" class="admin-notice">
|
||||
Admin Mode: Drag approved bookings to reschedule
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="loading && !confirmModal.show" class="loading">Loading calendar...</div>
|
||||
<FullCalendar v-if="!loading" :options="calendarOptions" />
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div v-if="confirmModal.show" class="modal-overlay" @click.self="cancelReschedule">
|
||||
<div class="modal-content">
|
||||
<h3>Confirm Reschedule</h3>
|
||||
<p>Reschedule this booking?</p>
|
||||
|
||||
<div class="time-comparison">
|
||||
<div class="old-time">
|
||||
<strong>Old Time:</strong><br />
|
||||
{{ formatDateTime(confirmModal.oldStart) }} - {{ formatDateTime(confirmModal.oldEnd) }}
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="new-time">
|
||||
<strong>New Time:</strong><br />
|
||||
{{ formatDateTime(confirmModal.newStart) }} - {{ formatDateTime(confirmModal.newEnd) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button @click="confirmReschedule" :disabled="modalLoading" class="btn-primary">
|
||||
{{ modalLoading ? 'Saving...' : 'Confirm' }}
|
||||
</button>
|
||||
<button @click="cancelReschedule" :disabled="modalLoading" class="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import type { CalendarOptions, EventInput, DatesSetArg, EventDropArg, EventResizeDoneArg } from '@fullcalendar/core'
|
||||
import { bookingsApi, adminBookingsApi, handleApiError } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Booking } from '@/types'
|
||||
|
||||
interface Props {
|
||||
spaceId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const bookings = ref<Booking[]>([])
|
||||
const loading = ref(false)
|
||||
const modalLoading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
interface ConfirmModal {
|
||||
show: boolean
|
||||
booking: any
|
||||
oldStart: Date | null
|
||||
oldEnd: Date | null
|
||||
newStart: Date | null
|
||||
newEnd: Date | null
|
||||
revertFunc: (() => void) | null
|
||||
}
|
||||
|
||||
const confirmModal = ref<ConfirmModal>({
|
||||
show: false,
|
||||
booking: null,
|
||||
oldStart: null,
|
||||
oldEnd: null,
|
||||
newStart: null,
|
||||
newEnd: null,
|
||||
revertFunc: null
|
||||
})
|
||||
|
||||
// Admin can edit, users see read-only
|
||||
const isEditable = computed(() => authStore.user?.role === 'admin')
|
||||
|
||||
// Status to color mapping
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: '#FFA500',
|
||||
approved: '#4CAF50',
|
||||
rejected: '#F44336',
|
||||
canceled: '#9E9E9E'
|
||||
}
|
||||
|
||||
// Convert bookings to FullCalendar events
|
||||
const events = computed<EventInput[]>(() => {
|
||||
return bookings.value.map((booking) => ({
|
||||
id: String(booking.id),
|
||||
title: booking.title,
|
||||
start: booking.start_datetime,
|
||||
end: booking.end_datetime,
|
||||
backgroundColor: STATUS_COLORS[booking.status] || '#9E9E9E',
|
||||
borderColor: STATUS_COLORS[booking.status] || '#9E9E9E',
|
||||
extendedProps: {
|
||||
status: booking.status,
|
||||
description: booking.description
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
// Handle event drop (drag)
|
||||
const handleEventDrop = (info: EventDropArg) => {
|
||||
const booking = info.event
|
||||
const oldStart = info.oldEvent.start
|
||||
const oldEnd = info.oldEvent.end
|
||||
const newStart = info.event.start
|
||||
const newEnd = info.event.end
|
||||
|
||||
// Show confirmation modal
|
||||
confirmModal.value = {
|
||||
show: true,
|
||||
booking: booking,
|
||||
oldStart: oldStart,
|
||||
oldEnd: oldEnd,
|
||||
newStart: newStart,
|
||||
newEnd: newEnd,
|
||||
revertFunc: info.revert
|
||||
}
|
||||
}
|
||||
|
||||
// Handle event resize
|
||||
const handleEventResize = (info: EventResizeDoneArg) => {
|
||||
const booking = info.event
|
||||
const oldStart = info.oldEvent.start
|
||||
const oldEnd = info.oldEvent.end
|
||||
const newStart = info.event.start
|
||||
const newEnd = info.event.end
|
||||
|
||||
// Show confirmation modal
|
||||
confirmModal.value = {
|
||||
show: true,
|
||||
booking: booking,
|
||||
oldStart: oldStart,
|
||||
oldEnd: oldEnd,
|
||||
newStart: newStart,
|
||||
newEnd: newEnd,
|
||||
revertFunc: info.revert
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm reschedule
|
||||
const confirmReschedule = async () => {
|
||||
if (!confirmModal.value.newStart || !confirmModal.value.newEnd) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
modalLoading.value = true
|
||||
|
||||
// Call reschedule API
|
||||
await adminBookingsApi.reschedule(parseInt(confirmModal.value.booking.id), {
|
||||
start_datetime: confirmModal.value.newStart.toISOString(),
|
||||
end_datetime: confirmModal.value.newEnd.toISOString()
|
||||
})
|
||||
|
||||
// Success - reload events
|
||||
await loadBookings(
|
||||
confirmModal.value.newStart < confirmModal.value.oldStart!
|
||||
? confirmModal.value.newStart
|
||||
: confirmModal.value.oldStart!,
|
||||
confirmModal.value.newEnd > confirmModal.value.oldEnd!
|
||||
? confirmModal.value.newEnd
|
||||
: confirmModal.value.oldEnd!
|
||||
)
|
||||
|
||||
confirmModal.value.show = false
|
||||
} catch (err: any) {
|
||||
// Error - revert the change
|
||||
if (confirmModal.value.revertFunc) {
|
||||
confirmModal.value.revertFunc()
|
||||
}
|
||||
|
||||
const errorMsg = err.response?.data?.detail || 'Failed to reschedule booking'
|
||||
error.value = errorMsg
|
||||
|
||||
// Clear error after 5 seconds
|
||||
setTimeout(() => {
|
||||
error.value = ''
|
||||
}, 5000)
|
||||
|
||||
confirmModal.value.show = false
|
||||
} finally {
|
||||
modalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel reschedule
|
||||
const cancelReschedule = () => {
|
||||
// Revert the visual change
|
||||
if (confirmModal.value.revertFunc) {
|
||||
confirmModal.value.revertFunc()
|
||||
}
|
||||
confirmModal.value.show = false
|
||||
}
|
||||
|
||||
// Format datetime for display
|
||||
const formatDateTime = (date: Date | null) => {
|
||||
if (!date) return ''
|
||||
return date.toLocaleString('ro-RO', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Load bookings for a date range
|
||||
const loadBookings = async (start: Date, end: Date) => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const startStr = start.toISOString()
|
||||
const endStr = end.toISOString()
|
||||
bookings.value = await bookingsApi.getForSpace(props.spaceId, startStr, endStr)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle date range changes
|
||||
const handleDatesSet = (arg: DatesSetArg) => {
|
||||
loadBookings(arg.start, arg.end)
|
||||
}
|
||||
|
||||
// FullCalendar options
|
||||
const calendarOptions = computed<CalendarOptions>(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
||||
},
|
||||
events: events.value,
|
||||
datesSet: handleDatesSet,
|
||||
editable: isEditable.value, // Enable drag/resize for admins
|
||||
eventStartEditable: isEditable.value,
|
||||
eventDurationEditable: isEditable.value,
|
||||
selectable: false,
|
||||
selectMirror: true,
|
||||
dayMaxEvents: true,
|
||||
weekends: true,
|
||||
height: 'auto',
|
||||
eventTimeFormat: {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
},
|
||||
slotLabelFormat: {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
},
|
||||
// Drag callback
|
||||
eventDrop: handleEventDrop,
|
||||
// Resize callback
|
||||
eventResize: handleEventResize,
|
||||
// Event rendering
|
||||
eventDidMount: (info) => {
|
||||
// Only approved bookings are draggable
|
||||
if (info.event.extendedProps.status !== 'approved') {
|
||||
info.el.style.cursor = 'default'
|
||||
}
|
||||
},
|
||||
// Event allow callback
|
||||
eventAllow: (dropInfo, draggedEvent) => {
|
||||
// Only allow dragging approved bookings
|
||||
return draggedEvent.extendedProps.status === 'approved'
|
||||
}
|
||||
}))
|
||||
|
||||
// Load initial bookings on mount
|
||||
onMounted(() => {
|
||||
const now = new Date()
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||
loadBookings(startOfMonth, endOfMonth)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.space-calendar {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.admin-notice {
|
||||
background: #e3f2fd;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
color: #1976d2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
margin-bottom: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.time-comparison {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 20px 0;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.old-time,
|
||||
.new-time {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.old-time strong,
|
||||
.new-time strong {
|
||||
color: #374151;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 24px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #93c5fd;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* FullCalendar custom styles */
|
||||
:deep(.fc) {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
:deep(.fc-button) {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
:deep(.fc-button:hover) {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
:deep(.fc-button-active) {
|
||||
background: #1d4ed8 !important;
|
||||
border-color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
:deep(.fc-daygrid-day-number) {
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.fc-col-header-cell-cushion) {
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.fc-event) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.fc-event-title) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Draggable events styling */
|
||||
:deep(.fc-event.fc-draggable) {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
:deep(.fc-event:not(.fc-draggable)) {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user