feat: implement citizen report creation, detail view, and chat system
- Create report creation page with step-by-step media uploads and categories loading - Create report details view page with history events log and messaging section - Implement MessageComposer and MessageList components for chat workflow - Implement MediaUploader with drag-and-drop support, file type, and size validation - Implement Pagination component with dynamic buttons and ellipsis support - Implement user profile settings summary page and notifications listing page
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
modelValue?: File[]
|
||||
maxFiles?: number
|
||||
acceptedTypes?: string
|
||||
maxFileSize?: number // in MB
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', files: File[]): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
maxFiles: 5,
|
||||
acceptedTypes: 'image/*,video/*',
|
||||
maxFileSize: 50, // 50MB
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const isDragging = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const files = computed({
|
||||
get: () => props.modelValue || [],
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const handleFilesSelected = (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles) return
|
||||
error.value = ''
|
||||
|
||||
const newFiles = Array.from(selectedFiles)
|
||||
|
||||
// Validazione numero file
|
||||
if (files.value.length + newFiles.length > props.maxFiles) {
|
||||
error.value = `Massimo ${props.maxFiles} file consentiti`
|
||||
return
|
||||
}
|
||||
|
||||
// Validazione dimensione
|
||||
for (const file of newFiles) {
|
||||
if (file.size > props.maxFileSize! * 1024 * 1024) {
|
||||
error.value = `File ${file.name} supera il limite di ${props.maxFileSize}MB`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
files.value = [...files.value, ...newFiles]
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const openFileDialog = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
const mb = Math.round(bytes / (1024 * 1024) * 10) / 10
|
||||
return mb > 0 ? `${mb}MB` : 'Piccolo'
|
||||
}
|
||||
|
||||
const getMediaType = (file: File): string => {
|
||||
return file.type.startsWith('video/') ? 'Video' : 'Immagine'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-uploader">
|
||||
<!-- Area trascinamento -->
|
||||
<div
|
||||
class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer"
|
||||
:class="{ 'border-primary': isDragging, 'bg-light': !isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="isDragging = false; handleFilesSelected(($event as DragEvent).dataTransfer?.files || null)"
|
||||
@click="!disabled && openFileDialog()"
|
||||
>
|
||||
<BIIcon name="it-upload" size="xl" color="primary" class="mb-3" />
|
||||
<div class="h6 font-weight-bold">
|
||||
Trascina i file qui o <span class="text-primary">clicca per selezionare</span>
|
||||
</div>
|
||||
<small class="text-muted d-block">
|
||||
Massimo {{ maxFiles }} file, fino a {{ maxFileSize }}MB per file
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Input nascosto -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
:multiple="props.maxFiles > 1"
|
||||
:accept="acceptedTypes"
|
||||
:disabled="disabled"
|
||||
style="display: none"
|
||||
@change="handleFilesSelected(($event.target as HTMLInputElement).files)"
|
||||
/>
|
||||
|
||||
<!-- Errori -->
|
||||
<div v-if="error" class="alert alert-danger mt-3 mb-0" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Lista file -->
|
||||
<div v-if="files.length > 0" class="mt-4">
|
||||
<h6 class="font-weight-bold mb-3">File caricati ({{ files.length }}/{{ maxFiles }})</h6>
|
||||
<div class="list-group">
|
||||
<div v-for="(file, idx) in files" :key="`file-${idx}`" class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<BIIcon
|
||||
:name="file.type.startsWith('video/') ? 'it-video' : 'it-image'"
|
||||
size="lg"
|
||||
color="primary"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-weight-semibold small">{{ file.name }}</div>
|
||||
<small class="text-muted">{{ getMediaType(file) }} • {{ formatFileSize(file.size) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link p-0"
|
||||
@click="removeFile(idx)"
|
||||
:disabled="disabled"
|
||||
:aria-label="`Rimuovi ${file.name}`"
|
||||
>
|
||||
<BIIcon name="it-close-circle" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-area {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(.border-primary) {
|
||||
background-color: rgba(0, 111, 157, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.border-2 {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
interface Emits {
|
||||
(e: 'send', message: string): void
|
||||
}
|
||||
|
||||
// Unica dichiarazione di defineEmits
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const message = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (message.value.trim() === '') return
|
||||
|
||||
emit('send', message.value)
|
||||
message.value = ''
|
||||
}
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
// Ctrl/Cmd + Enter per inviare
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
// Previene l'andata a capo predefinita della textarea
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-composer mt-4 border-top pt-3">
|
||||
<!-- Non serve passare emit qui -->
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="input-group">
|
||||
<textarea
|
||||
v-model="message"
|
||||
class="form-control"
|
||||
placeholder="Scrivi un messaggio... (Ctrl+Enter per inviare)"
|
||||
rows="2"
|
||||
:disabled="isSubmitting"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="isSubmitting || message.trim() === ''"
|
||||
>
|
||||
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
<BIIcon v-else name="it-arrow-right" size="sm" color="white" class="me-1" />
|
||||
Invia
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-2">
|
||||
💡 <strong>Tip:</strong> Premi Ctrl+Enter per inviare velocemente
|
||||
</small>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-composer {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message } from '~/types/api'
|
||||
|
||||
interface Props {
|
||||
messages: Message[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return 'Ieri'
|
||||
} else {
|
||||
return date.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: '2-digit' })
|
||||
}
|
||||
}
|
||||
|
||||
const auth = useAuth()
|
||||
|
||||
const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userId
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-list">
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Caricamento...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="messages.length === 0" class="alert alert-info text-center" role="alert">
|
||||
Nessun messaggio disponibile. Sii il primo a scrivere!
|
||||
</div>
|
||||
|
||||
<div v-else class="messages-container">
|
||||
<div v-for="msg in messages" :key="msg.id" class="message-bubble mb-3">
|
||||
<div
|
||||
class="message-bubble-content p-3 rounded-3"
|
||||
:class="{
|
||||
'bg-primary text-white': isCurrentUser(msg.user_id),
|
||||
'bg-light border': !isCurrentUser(msg.user_id)
|
||||
}"
|
||||
>
|
||||
<!-- Nome mittente (solo per messaggi non miei) -->
|
||||
<div v-if="!isCurrentUser(msg.user_id)" class="small font-weight-bold mb-2 text-primary">
|
||||
{{ msg.first_name }} {{ msg.last_name }}
|
||||
</div>
|
||||
|
||||
<!-- Corpo messaggio -->
|
||||
<div class="message-body" :class="{ 'text-break': true }">
|
||||
{{ msg.body }}
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<div class="small mt-2" :class="{ 'text-white-50': isCurrentUser(msg.user_id), 'text-muted': !isCurrentUser(msg.user_id) }">
|
||||
{{ formatDate(msg.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble-content {
|
||||
max-width: 75%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
currentPage: number
|
||||
perPage: number
|
||||
totalItems: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'page-change', page: number): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentPage: 1,
|
||||
perPage: 10,
|
||||
totalItems: 0
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const totalPages = computed(() => Math.ceil(props.totalItems / props.perPage))
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
emit('page-change', page)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const pageNumbers = computed(() => {
|
||||
const pages: (number | string)[] = []
|
||||
const maxVisible = 5
|
||||
|
||||
if (totalPages.value <= maxVisible) {
|
||||
for (let i = 1; i <= totalPages.value; i++) pages.push(i)
|
||||
} else {
|
||||
if (props.currentPage <= 3) {
|
||||
for (let i = 1; i <= maxVisible - 1; i++) pages.push(i)
|
||||
pages.push('...')
|
||||
pages.push(totalPages.value)
|
||||
} else if (props.currentPage >= totalPages.value - 2) {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = totalPages.value - (maxVisible - 2); i <= totalPages.value; i++) pages.push(i)
|
||||
} else {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = props.currentPage - 1; i <= props.currentPage + 1; i++) pages.push(i)
|
||||
pages.push('...')
|
||||
pages.push(totalPages.value)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="totalPages > 1" class="d-flex justify-content-center mt-5 mb-5">
|
||||
<ul class="pagination mb-0">
|
||||
<!-- Pagina Precedente -->
|
||||
<li class="page-item" :class="{ disabled: currentPage === 1 }">
|
||||
<button
|
||||
class="page-link"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage === 1"
|
||||
aria-label="Pagina precedente"
|
||||
>
|
||||
<span aria-hidden="true">«</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Numeri Pagine -->
|
||||
<li v-for="page in pageNumbers" :key="`page-${page}`" class="page-item" :class="{ active: page === currentPage }">
|
||||
<template v-if="page === '...'">
|
||||
<span class="page-link">...</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="page-link"
|
||||
@click="goToPage(page as number)"
|
||||
:aria-label="`Vai a pagina ${page}`"
|
||||
:aria-current="page === currentPage ? 'page' : undefined"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</template>
|
||||
</li>
|
||||
|
||||
<!-- Pagina Successiva -->
|
||||
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
|
||||
<button
|
||||
class="page-link"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage === totalPages"
|
||||
aria-label="Pagina successiva"
|
||||
>
|
||||
<span aria-hidden="true">»</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Info Pagina -->
|
||||
<div v-if="totalPages > 1" class="text-center text-muted small mt-3">
|
||||
Pagina {{ currentPage }} di {{ totalPages }} ({{ totalItems }} elementi totali)
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pagination {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, CreateReportRequest } from '~/types/api'
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean
|
||||
categories?: Category[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', report: CreateReportRequest, media: File[]): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLoading: false,
|
||||
categories: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const form = ref({
|
||||
category_id: null as number | null,
|
||||
latitude: null as number | null,
|
||||
longitude: null as number | null,
|
||||
address: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const media = ref<File[]>([])
|
||||
const errors = ref<Record<string, string>>({})
|
||||
const successMessage = ref(false)
|
||||
const isGettingLocation = ref(false)
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
errors.value = {}
|
||||
|
||||
if (!form.value.category_id) {
|
||||
errors.value.category_id = 'Seleziona una categoria'
|
||||
}
|
||||
if (!form.value.latitude || !form.value.longitude) {
|
||||
errors.value.location = 'Imposta la posizione'
|
||||
}
|
||||
if (!form.value.address?.trim()) {
|
||||
errors.value.address = 'Inserisci l\'indirizzo'
|
||||
}
|
||||
if (!form.value.description?.trim() || form.value.description.length < 10) {
|
||||
errors.value.description = 'Descrivi il problema (minimo 10 caratteri)'
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length === 0
|
||||
}
|
||||
|
||||
const getGeolocation = async () => {
|
||||
if (!navigator.geolocation) {
|
||||
errors.value.location = 'Geolocalizzazione non disponibile nel tuo browser'
|
||||
return
|
||||
}
|
||||
|
||||
isGettingLocation.value = true
|
||||
errors.value.location = ''
|
||||
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject)
|
||||
})
|
||||
|
||||
form.value.latitude = Number(position.coords.latitude.toFixed(6))
|
||||
form.value.longitude = Number(position.coords.longitude.toFixed(6))
|
||||
|
||||
// Richiedi indirizzo da coordinate (reverse geocoding)
|
||||
// Se il backend non lo fa, userà le coord così come sono
|
||||
} catch (error: any) {
|
||||
errors.value.location = 'Impossibile ottenere la posizione. Inserisci manualmente.'
|
||||
} finally {
|
||||
isGettingLocation.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
const reportData: CreateReportRequest = {
|
||||
category_id: form.value.category_id!,
|
||||
latitude: form.value.latitude!,
|
||||
longitude: form.value.longitude!,
|
||||
address: form.value.address,
|
||||
description: form.value.description,
|
||||
}
|
||||
|
||||
emit('submit', reportData, media.value)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
category_id: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
address: '',
|
||||
description: '',
|
||||
}
|
||||
media.value = []
|
||||
errors.value = {}
|
||||
}
|
||||
|
||||
// Osserva cambio categoria
|
||||
watch(() => form.value.category_id, () => {
|
||||
if (errors.value.category_id) {
|
||||
errors.value.category_id = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="report-form">
|
||||
<!-- Successo -->
|
||||
<div v-if="successMessage" class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
Segnalazione creata con successo! Reindirizzamento...
|
||||
<button type="button" class="btn-close" @click="successMessage = false"></button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<!-- CATEGORIA -->
|
||||
<div class="mb-4">
|
||||
<label for="category" class="form-label fw-bold">
|
||||
<BIIcon name="it-list" size="sm" color="primary" class="me-2" />
|
||||
Categoria del problema *
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
v-model.number="form.category_id"
|
||||
class="form-select"
|
||||
:class="{ 'is-invalid': errors.category_id }"
|
||||
required
|
||||
>
|
||||
<option :value="null">-- Seleziona una categoria --</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="errors.category_id" class="invalid-feedback">
|
||||
{{ errors.category_id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GEOLOCALIZZAZIONE -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">
|
||||
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
|
||||
Posizione *
|
||||
</label>
|
||||
|
||||
<div v-if="form.latitude && form.longitude" class="alert alert-info mb-3">
|
||||
<strong>Coordinate:</strong> {{ form.latitude }}, {{ form.longitude }}
|
||||
<button type="button" class="btn btn-sm btn-link float-end" @click="form.latitude = null; form.longitude = null">
|
||||
Cancella
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary w-100 mb-3"
|
||||
@click="getGeolocation"
|
||||
:disabled="isGettingLocation"
|
||||
>
|
||||
<span v-if="isGettingLocation" class="spinner-border spinner-border-sm me-2"></span>
|
||||
<BIIcon v-else name="it-location" size="sm" class="me-2" />
|
||||
{{ isGettingLocation ? 'Localizzazione in corso...' : 'Usa posizione attuale' }}
|
||||
</button>
|
||||
|
||||
<div class="alert alert-warning mb-3 small" role="alert">
|
||||
Se la geolocalizzazione non funziona, inserisci manualmente coordinate o indirizzo approssimativo.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<input
|
||||
v-model.number="form.latitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': errors.location }"
|
||||
placeholder="Latitudine (es. 45.5)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<input
|
||||
v-model.number="form.longitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': errors.location }"
|
||||
placeholder="Longitudine (es. 10.2)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errors.location" class="invalid-feedback d-block">
|
||||
{{ errors.location }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- INDIRIZZO -->
|
||||
<div class="mb-4">
|
||||
<label for="address" class="form-label fw-bold">
|
||||
<BIIcon name="it-home" size="sm" color="primary" class="me-2" />
|
||||
Indirizzo *
|
||||
</label>
|
||||
<input
|
||||
id="address"
|
||||
v-model="form.address"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': errors.address }"
|
||||
placeholder="Es. Via Roma 123, Milano"
|
||||
required
|
||||
/>
|
||||
<div v-if="errors.address" class="invalid-feedback">
|
||||
{{ errors.address }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESCRIZIONE -->
|
||||
<div class="mb-4">
|
||||
<label for="description" class="form-label fw-bold">
|
||||
<BIIcon name="it-write" size="sm" color="primary" class="me-2" />
|
||||
Descrizione del problema *
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': errors.description }"
|
||||
rows="5"
|
||||
placeholder="Descrivi dettagliatamente il problema (tipo buca stradale, pericolo, ecc.)"
|
||||
required
|
||||
/>
|
||||
<small class="form-text text-muted d-block mt-1">
|
||||
{{ form.description.length }} caratteri (minimo 10)
|
||||
</small>
|
||||
<div v-if="errors.description" class="invalid-feedback d-block">
|
||||
{{ errors.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MEDIA -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">
|
||||
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
|
||||
Allega foto/video (opzionale)
|
||||
</label>
|
||||
<MediaUploader v-model="media" :max-files="5" :max-file-size="50" />
|
||||
</div>
|
||||
|
||||
<!-- BOTTONI -->
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-between">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
@click="resetForm"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-lg"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
|
||||
{{ isLoading ? 'Invio segnalazione...' : 'Invia segnalazione' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.report-form {
|
||||
background-color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user