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:
2026-06-12 19:04:19 +02:00 Unverified
parent 4c6367b328
commit 47327d3a69
9 changed files with 1800 additions and 0 deletions
+163
View File
@@ -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>
+83
View File
@@ -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>
+106
View File
@@ -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>
+115
View File
@@ -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">&laquo;</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">&raquo;</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>
+298
View File
@@ -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>