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:
373
frontend/src/views/Settings.vue
Normal file
373
frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="settings">
|
||||
<h2>Global Booking Settings</h2>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<div class="card">
|
||||
<h3>Booking Rules Configuration</h3>
|
||||
|
||||
<div v-if="loadingSettings" class="loading">Loading settings...</div>
|
||||
|
||||
<form v-else @submit.prevent="handleSubmit" class="settings-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="minDuration">Minimum Duration (minutes)</label>
|
||||
<input
|
||||
id="minDuration"
|
||||
v-model.number="formData.min_duration_minutes"
|
||||
type="number"
|
||||
required
|
||||
min="15"
|
||||
max="480"
|
||||
placeholder="30"
|
||||
/>
|
||||
<small>Between 15 and 480 minutes</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="maxDuration">Maximum Duration (minutes)</label>
|
||||
<input
|
||||
id="maxDuration"
|
||||
v-model.number="formData.max_duration_minutes"
|
||||
type="number"
|
||||
required
|
||||
min="30"
|
||||
max="1440"
|
||||
placeholder="480"
|
||||
/>
|
||||
<small>Between 30 and 1440 minutes (24h)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="workStart">Working Hours Start (hour)</label>
|
||||
<input
|
||||
id="workStart"
|
||||
v-model.number="formData.working_hours_start"
|
||||
type="number"
|
||||
required
|
||||
min="0"
|
||||
max="23"
|
||||
placeholder="8"
|
||||
/>
|
||||
<small>0-23 (e.g., 8 = 8:00 AM)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="workEnd">Working Hours End (hour)</label>
|
||||
<input
|
||||
id="workEnd"
|
||||
v-model.number="formData.working_hours_end"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="24"
|
||||
placeholder="20"
|
||||
/>
|
||||
<small>1-24 (e.g., 20 = 8:00 PM)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="maxBookings">Max Bookings per Day per User</label>
|
||||
<input
|
||||
id="maxBookings"
|
||||
v-model.number="formData.max_bookings_per_day_per_user"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="20"
|
||||
placeholder="3"
|
||||
/>
|
||||
<small>Between 1 and 20</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="minCancel">Min Hours Before Cancel</label>
|
||||
<input
|
||||
id="minCancel"
|
||||
v-model.number="formData.min_hours_before_cancel"
|
||||
type="number"
|
||||
required
|
||||
min="0"
|
||||
max="72"
|
||||
placeholder="2"
|
||||
/>
|
||||
<small>Between 0 and 72 hours</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="success" class="success">{{ success }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Card -->
|
||||
<div class="card info-card">
|
||||
<h4>About These Settings</h4>
|
||||
<ul>
|
||||
<li><strong>Duration:</strong> Controls minimum and maximum booking length</li>
|
||||
<li><strong>Working Hours:</strong> Bookings outside these hours will be rejected</li>
|
||||
<li><strong>Max Bookings:</strong> Limits how many bookings a user can make per day</li>
|
||||
<li><strong>Cancel Policy:</strong> Users cannot cancel bookings too close to start time</li>
|
||||
</ul>
|
||||
<p class="note">These rules apply to all new booking requests.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, handleApiError } from '@/services/api'
|
||||
import type { Settings } from '@/types'
|
||||
|
||||
const loadingSettings = ref(true)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const formData = ref<Omit<Settings, 'id'>>({
|
||||
min_duration_minutes: 30,
|
||||
max_duration_minutes: 480,
|
||||
working_hours_start: 8,
|
||||
working_hours_end: 20,
|
||||
max_bookings_per_day_per_user: 3,
|
||||
min_hours_before_cancel: 2
|
||||
})
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
loadingSettings.value = true
|
||||
const settings = await settingsApi.get()
|
||||
formData.value = {
|
||||
min_duration_minutes: settings.min_duration_minutes,
|
||||
max_duration_minutes: settings.max_duration_minutes,
|
||||
working_hours_start: settings.working_hours_start,
|
||||
working_hours_end: settings.working_hours_end,
|
||||
max_bookings_per_day_per_user: settings.max_bookings_per_day_per_user,
|
||||
min_hours_before_cancel: settings.min_hours_before_cancel
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loadingSettings.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
// Validate all fields are positive
|
||||
if (
|
||||
formData.value.min_duration_minutes <= 0 ||
|
||||
formData.value.max_duration_minutes <= 0 ||
|
||||
formData.value.working_hours_start < 0 ||
|
||||
formData.value.working_hours_end < 0 ||
|
||||
formData.value.max_bookings_per_day_per_user <= 0 ||
|
||||
formData.value.min_hours_before_cancel < 0
|
||||
) {
|
||||
error.value = 'All fields must be positive values'
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate min < max duration
|
||||
if (formData.value.min_duration_minutes >= formData.value.max_duration_minutes) {
|
||||
error.value = 'Minimum duration must be less than maximum duration'
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate working hours start < end
|
||||
if (formData.value.working_hours_start >= formData.value.working_hours_end) {
|
||||
error.value = 'Working hours start must be less than working hours end'
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate working hours are within 0-23 for start and 1-24 for end
|
||||
if (formData.value.working_hours_start < 0 || formData.value.working_hours_start > 23) {
|
||||
error.value = 'Working hours start must be between 0 and 23'
|
||||
return false
|
||||
}
|
||||
|
||||
if (formData.value.working_hours_end < 1 || formData.value.working_hours_end > 24) {
|
||||
error.value = 'Working hours end must be between 1 and 24'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
// Client-side validation
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await settingsApi.update(formData.value)
|
||||
success.value = 'Settings updated successfully!'
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error.value = handleApiError(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h3, h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background-color: #fee;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 4px;
|
||||
color: #c33;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 0.75rem;
|
||||
background-color: #efe;
|
||||
border: 1px solid #cfc;
|
||||
border-radius: 4px;
|
||||
color: #3c3;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.info-card ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.info-card li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user