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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Il Mio Profilo - Visione' })
|
||||||
|
|
||||||
|
const { user, fetchRewards } = useAuth()
|
||||||
|
|
||||||
|
// Fetch rewards/punti
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
|
||||||
|
const { data: rewardsData } = await useAsyncData(() =>
|
||||||
|
fetchRewards({ page: page.value, per_page: perPage.value })
|
||||||
|
)
|
||||||
|
|
||||||
|
const rewards = computed(() => (rewardsData.value as any)?.reward_events || [])
|
||||||
|
const totalPoints = computed(() => (rewardsData.value as any)?.points || 0)
|
||||||
|
const totalRewards = computed(() => rewards.value.length)
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
page.value = newPage
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return new Date(dateString).toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
useAuth().logout()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- HERO -->
|
||||||
|
<div class="bg-light border-bottom py-4 mb-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="h3 font-weight-bold mb-3">Il Mio Profilo</h1>
|
||||||
|
<p class="text-muted">Gestisci i tuoi dati, visualizza i tuoi punti e la cronologia delle segnalazioni</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO -->
|
||||||
|
<div class="container mb-5">
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<!-- SINISTRA: Profilo -->
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
|
||||||
|
<!-- DATI PERSONALI -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h5 class="card-title font-weight-bold mb-0">
|
||||||
|
<BIIcon name="it-user" size="sm" color="primary" class="me-2" />
|
||||||
|
Dati Personali
|
||||||
|
</h5>
|
||||||
|
<NuxtLink to="/settings/profile" class="btn btn-sm btn-outline-primary">
|
||||||
|
<BIIcon name="it-pencil" size="xs" class="me-1" />
|
||||||
|
Modifica
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<small class="text-muted d-block">Nome</small>
|
||||||
|
<strong>{{ user?.first_name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<small class="text-muted d-block">Cognome</small>
|
||||||
|
<strong>{{ user?.last_name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<small class="text-muted d-block">Email</small>
|
||||||
|
<strong>{{ user?.email }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<small class="text-muted d-block">Codice Fiscale</small>
|
||||||
|
<strong>{{ user?.fiscal_code }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<small class="text-muted d-block">Telefono</small>
|
||||||
|
<strong>{{ user?.phone || 'Non fornito' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<small class="text-muted d-block">Ruolo</small>
|
||||||
|
<span class="badge bg-info">{{ user?.role }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PUNTI CITTADINO -->
|
||||||
|
<div class="card card-bg card-highlight mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title font-weight-bold mb-4">
|
||||||
|
<BIIcon name="it-star" size="sm" color="warning" class="me-2" />
|
||||||
|
I Tuoi Punti Cittadino
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div class="text-center p-4 bg-light rounded mb-4">
|
||||||
|
<div class="display-3 font-weight-bold text-primary">{{ totalPoints }}</div>
|
||||||
|
<small class="text-muted">Segnalazioni risolte completate con successo</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<small>
|
||||||
|
<strong>Come guadagni punti?</strong><br>
|
||||||
|
Ricevi 1 punto per ogni segnalazione che risolviamo. Quando raggiungi determinati traguardi, potrai sbloccare riconoscimenti speciali!
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CRONOLOGIA PUNTI -->
|
||||||
|
<div class="card card-bg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title font-weight-bold mb-4">
|
||||||
|
<BIIcon name="it-history" size="sm" color="primary" class="me-2" />
|
||||||
|
Cronologia Punti (ultimi {{ rewards.length }})
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div v-if="rewards.length === 0" class="alert alert-info" role="alert">
|
||||||
|
Non hai ancora accumulato punti. Crea la tua prima segnalazione!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="small">Data</th>
|
||||||
|
<th scope="col" class="small">Segnalazione</th>
|
||||||
|
<th scope="col" class="text-center small">Punti</th>
|
||||||
|
<th scope="col" class="small">Motivo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="reward in rewards" :key="reward.id">
|
||||||
|
<td class="small">
|
||||||
|
<small class="text-muted">{{ formatDate(reward.created_at) }}</small>
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
<NuxtLink :to="`/reports/${reward.report_id}`" class="text-decoration-none">
|
||||||
|
#{{ reward.report_id }}
|
||||||
|
</NuxtLink>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge bg-success">+{{ reward.points }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ reward.reason }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PAGINATION -->
|
||||||
|
<Pagination
|
||||||
|
v-if="rewards.length > 0"
|
||||||
|
:current-page="page"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-items="totalRewards"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESTRA: Azioni rapide -->
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
|
||||||
|
<!-- NUOVA SEGNALAZIONE -->
|
||||||
|
<div class="card card-bg card-highlight mb-4">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<BIIcon name="it-plus-circle" size="xl" color="primary" class="mb-3" />
|
||||||
|
<h6 class="font-weight-bold mb-3">Crea una nuova segnalazione</h6>
|
||||||
|
<p class="small text-muted mb-3">Aiutaci a migliorare il tuo quartiere</p>
|
||||||
|
<NuxtLink to="/reports/create" class="btn btn-primary w-100">
|
||||||
|
Segnala ora
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LE MIE SEGNALAZIONI -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-list" size="sm" color="primary" class="me-2" />
|
||||||
|
Le Mie Segnalazioni
|
||||||
|
</h6>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-primary w-100 btn-sm">
|
||||||
|
Vai alla Dashboard
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NOTIFICHE -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-bell" size="sm" color="primary" class="me-2" />
|
||||||
|
Centro Notifiche
|
||||||
|
</h6>
|
||||||
|
<NuxtLink to="/notifications" class="btn btn-outline-primary w-100 btn-sm">
|
||||||
|
Visualizza notifiche
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IMPOSTAZIONI & LOGOUT -->
|
||||||
|
<div class="card card-bg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-settings" size="sm" color="primary" class="me-2" />
|
||||||
|
Account
|
||||||
|
</h6>
|
||||||
|
<NuxtLink to="/settings/password" class="btn btn-outline-secondary w-100 btn-sm mb-2">
|
||||||
|
Cambia Password
|
||||||
|
</NuxtLink>
|
||||||
|
<button type="button" class="btn btn-outline-danger w-100 btn-sm" @click="handleLogout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-highlight {
|
||||||
|
border-color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-3 {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: rgba(0, 111, 157, 0.05);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Notifiche - Visione' })
|
||||||
|
|
||||||
|
const { fetchNotifications, markNotificationRead } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(15)
|
||||||
|
|
||||||
|
const { data: notificationsData, refresh: refreshNotifications } = await useAsyncData(
|
||||||
|
'notifications',
|
||||||
|
() => fetchNotifications({ page: page.value, per_page: perPage.value })
|
||||||
|
)
|
||||||
|
|
||||||
|
const notifications = computed(() => notificationsData.value?.notifications || [])
|
||||||
|
const totalNotifications = computed(() => notificationsData.value?.notifications?.length || 0)
|
||||||
|
const unreadCount = computed(() => notifications.value.filter((n: any) => !n.read_at).length)
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
page.value = newPage
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkAsRead = async (notificationId: number) => {
|
||||||
|
try {
|
||||||
|
await markNotificationRead(notificationId)
|
||||||
|
await refreshNotifications()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking notification as read:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNotificationClick = async (notification: any) => {
|
||||||
|
if (!notification.read_at) {
|
||||||
|
await handleMarkAsRead(notification.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.report_id) {
|
||||||
|
router.push(`/reports/${notification.report_id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Proprio ora'
|
||||||
|
if (diffMins < 60) return `${diffMins}m fa`
|
||||||
|
if (diffHours < 24) return `${diffHours}h fa`
|
||||||
|
if (diffDays < 7) return `${diffDays}d fa`
|
||||||
|
|
||||||
|
return date.toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNotificationIcon = (type: string): string => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
'report_created': 'it-pencil',
|
||||||
|
'report_assigned': 'it-check-circle',
|
||||||
|
'report_updated': 'it-refresh',
|
||||||
|
'report_resolved': 'it-check-circle',
|
||||||
|
'message_received': 'it-comment',
|
||||||
|
'feedback_requested': 'it-star',
|
||||||
|
'reward_earned': 'it-star',
|
||||||
|
}
|
||||||
|
return icons[type] || 'it-bell'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNotificationColor = (type: string): string => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
'report_created': 'primary',
|
||||||
|
'report_assigned': 'info',
|
||||||
|
'report_updated': 'info',
|
||||||
|
'report_resolved': 'success',
|
||||||
|
'message_received': 'primary',
|
||||||
|
'feedback_requested': 'warning',
|
||||||
|
'reward_earned': 'warning',
|
||||||
|
}
|
||||||
|
return colors[type] || 'secondary'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- HERO -->
|
||||||
|
<div class="bg-light border-bottom py-4 mb-4">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="h3 font-weight-bold mb-2">
|
||||||
|
<BIIcon name="it-bell" size="sm" color="primary" class="me-2" />
|
||||||
|
Centro Notifiche
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
{{ unreadCount > 0 ? `Hai ${unreadCount} notifiche non lette` : 'Tutte le notifiche lette' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO -->
|
||||||
|
<div class="container mb-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-10 offset-lg-1">
|
||||||
|
|
||||||
|
<!-- FILTRI (Opzionale) -->
|
||||||
|
<div class="d-flex gap-2 mb-4">
|
||||||
|
<button class="btn btn-sm btn-outline-primary active">Tutti</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary">Solo non letti</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LISTA NOTIFICHE -->
|
||||||
|
<div v-if="notifications.length === 0" class="text-center py-5">
|
||||||
|
<BIIcon name="it-bell" size="xl" color="info" class="mb-3 d-block" />
|
||||||
|
<h6 class="text-muted">Nessuna notifica</h6>
|
||||||
|
<p class="small text-muted">Quando riceverai nuovi aggiornamenti sulle tue segnalazioni, appariranno qui.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="list-group">
|
||||||
|
<button
|
||||||
|
v-for="notification in notifications"
|
||||||
|
:key="notification.id"
|
||||||
|
type="button"
|
||||||
|
class="list-group-item list-group-item-action border-0 p-3 mb-3 rounded-2"
|
||||||
|
:class="{
|
||||||
|
'bg-light': !notification.read_at,
|
||||||
|
'bg-white': notification.read_at,
|
||||||
|
}"
|
||||||
|
@click="handleNotificationClick(notification)"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-items-start gap-3">
|
||||||
|
<!-- ICONA -->
|
||||||
|
<div class="flex-shrink-0 mt-1">
|
||||||
|
<div class="rounded-circle p-2" :class="`bg-${getNotificationColor(notification.type)}`" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<BIIcon :name="getNotificationIcon(notification.type)" size="sm" color="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO -->
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex align-items-start justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1 font-weight-bold" :class="{ 'text-dark': !notification.read_at }">
|
||||||
|
{{ notification.title }}
|
||||||
|
</h6>
|
||||||
|
<p class="mb-1 small" :class="{ 'text-muted': notification.read_at }">
|
||||||
|
{{ notification.body }}
|
||||||
|
</p>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ formatDate(notification.created_at) }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- INDICATORE NON LETTO -->
|
||||||
|
<div v-if="!notification.read_at" class="flex-shrink-0">
|
||||||
|
<span class="badge bg-primary rounded-pill">Nuovo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AZIONI RAPIDE -->
|
||||||
|
<div v-if="notification.report_id" class="mt-2 d-flex gap-2">
|
||||||
|
<NuxtLink :to="`/reports/${notification.report_id}`" class="btn btn-sm btn-outline-primary">
|
||||||
|
<BIIcon name="it-arrow-right" size="xs" class="me-1" />
|
||||||
|
Vedi segnalazione
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
v-if="!notification.read_at"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
@click.stop="handleMarkAsRead(notification.id)"
|
||||||
|
>
|
||||||
|
Segna come letta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PAGINATION -->
|
||||||
|
<Pagination
|
||||||
|
v-if="notifications.length > 0"
|
||||||
|
:current-page="page"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-items="totalNotifications"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-group-item {
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bg-light {
|
||||||
|
border-left: 4px solid #006f9d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-2 {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Dettaglio Segnalazione - Visione' })
|
||||||
|
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { getReportDetail, getMessages, addMessage, addFeedback, flagReport } = useReports()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const reportId = Number(route.params.id)
|
||||||
|
if (!reportId) {
|
||||||
|
navigateTo('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch dati
|
||||||
|
const { data: reportData, refresh: refreshReport } = await useAsyncData(`report-${reportId}`, () =>
|
||||||
|
getReportDetail(reportId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: messagesData, refresh: refreshMessages } = await useAsyncData(`messages-${reportId}`, () =>
|
||||||
|
getMessages(reportId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const report = computed(() => (reportData.value as any)?.report)
|
||||||
|
const media = computed(() => (reportData.value as any)?.media || [])
|
||||||
|
const statusHistory = computed(() => (reportData.value as any)?.history || [])
|
||||||
|
const messages = computed(() => (messagesData.value as any)?.messages || [])
|
||||||
|
|
||||||
|
const newMessage = ref('')
|
||||||
|
const isSubmittingMessage = ref(false)
|
||||||
|
const isFeedbackOpen = ref(false)
|
||||||
|
const feedbackRating = ref(5)
|
||||||
|
const feedbackComment = ref('')
|
||||||
|
const isSubmittingFeedback = ref(false)
|
||||||
|
const flagMessage = ref('')
|
||||||
|
const isFlagging = ref(false)
|
||||||
|
const selectedMediaIndex = ref(0)
|
||||||
|
const isGalleryOpen = ref(false)
|
||||||
|
|
||||||
|
// Status badge color
|
||||||
|
const getStatusColor = (status: string): string => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
OPEN: 'primary',
|
||||||
|
UNDER_REVIEW: 'info',
|
||||||
|
ASSIGNED: 'warning',
|
||||||
|
IN_PROGRESS: 'warning',
|
||||||
|
RESOLVED: 'success',
|
||||||
|
REJECTED: 'danger',
|
||||||
|
CLOSED: 'secondary',
|
||||||
|
}
|
||||||
|
return colors[status] || 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status label italiano
|
||||||
|
const getStatusLabel = (status: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
OPEN: 'Aperta',
|
||||||
|
UNDER_REVIEW: 'In revisione',
|
||||||
|
ASSIGNED: 'Assegnata',
|
||||||
|
IN_PROGRESS: 'In corso',
|
||||||
|
RESOLVED: 'Risolta',
|
||||||
|
REJECTED: 'Rifiutata',
|
||||||
|
CLOSED: 'Chiusa',
|
||||||
|
}
|
||||||
|
return labels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invia messaggio
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
if (!newMessage.value.trim()) return
|
||||||
|
|
||||||
|
isSubmittingMessage.value = true
|
||||||
|
try {
|
||||||
|
await addMessage(reportId, { body: newMessage.value })
|
||||||
|
newMessage.value = ''
|
||||||
|
await refreshMessages()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message:', error)
|
||||||
|
} finally {
|
||||||
|
isSubmittingMessage.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invia feedback (solo se risolta)
|
||||||
|
const handleSubmitFeedback = async () => {
|
||||||
|
isSubmittingFeedback.value = true
|
||||||
|
try {
|
||||||
|
await addFeedback(reportId, {
|
||||||
|
rating: feedbackRating.value,
|
||||||
|
comment: feedbackComment.value || undefined,
|
||||||
|
})
|
||||||
|
isFeedbackOpen.value = false
|
||||||
|
feedbackRating.value = 5
|
||||||
|
feedbackComment.value = ''
|
||||||
|
await refreshReport()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting feedback:', error)
|
||||||
|
} finally {
|
||||||
|
isSubmittingFeedback.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segnala abuso
|
||||||
|
const handleFlag = async () => {
|
||||||
|
if (!flagMessage.value.trim()) return
|
||||||
|
|
||||||
|
isFlagging.value = true
|
||||||
|
try {
|
||||||
|
await flagReport(reportId, {
|
||||||
|
reason: flagMessage.value,
|
||||||
|
})
|
||||||
|
flagMessage.value = ''
|
||||||
|
// Mostra messaggio di successo
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error flagging report:', error)
|
||||||
|
} finally {
|
||||||
|
isFlagging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = computed(() => report.value?.user_id === user.value?.id)
|
||||||
|
const canFeedback = computed(() => report.value?.status === 'RESOLVED' || report.value?.status === 'CLOSED')
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return new Date(dateString).toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="report">
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div class="bg-light border-bottom py-4">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-md-8">
|
||||||
|
<h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span class="badge" :class="`bg-${getStatusColor(report.status)}`">
|
||||||
|
{{ getStatusLabel(report.status) }}
|
||||||
|
</span>
|
||||||
|
<small class="text-muted">
|
||||||
|
<BIIcon name="it-clock" size="xs" class="me-1" />
|
||||||
|
Creata il {{ formatDate(report.created_at) }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO PRINCIPALE -->
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<!-- SINISTRA: Dettagli segnalazione -->
|
||||||
|
<div class="col-12 col-lg-8 mb-4">
|
||||||
|
|
||||||
|
<!-- INDIRIZZO E COORDINATE -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
|
||||||
|
Posizione
|
||||||
|
</h6>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>{{ report.address }}</strong>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
Coordinate: {{ report.latitude }}, {{ report.longitude }}
|
||||||
|
</small>
|
||||||
|
<small v-if="report.area_name" class="text-muted d-block">
|
||||||
|
Area: {{ report.area_name }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESCRIZIONE -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-write" size="sm" color="primary" class="me-2" />
|
||||||
|
Descrizione
|
||||||
|
</h6>
|
||||||
|
<p class="card-text text-break">{{ report.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GALLERY MEDIA -->
|
||||||
|
<div v-if="media.length > 0" class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
|
||||||
|
Allegati ({{ media.length }})
|
||||||
|
</h6>
|
||||||
|
<div class="row">
|
||||||
|
<div v-for="(m, idx) in media" :key="m.id" class="col-6 col-md-4 mb-3">
|
||||||
|
<div v-if="m.media_kind === 'IMAGE'" class="ratio ratio-1x1 bg-light rounded overflow-hidden">
|
||||||
|
<img :src="runtimeConfig.public.api + m.url" :alt="`Allegato ${idx + 1}`" class="object-fit-cover" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="ratio ratio-1x1 bg-dark rounded d-flex align-items-center justify-content-center">
|
||||||
|
<BIIcon name="it-video" size="xl" color="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CRONOLOGIA STATI -->
|
||||||
|
<div v-if="statusHistory.length > 0" class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-4">
|
||||||
|
<BIIcon name="it-history" size="sm" color="primary" class="me-2" />
|
||||||
|
Cronologia
|
||||||
|
</h6>
|
||||||
|
<div class="timeline">
|
||||||
|
<div v-for="item in statusHistory" :key="item.id" class="timeline-item mb-3">
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="timeline-marker"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<small class="text-muted">{{ formatDate(item.created_at) }}</small>
|
||||||
|
<div class="small font-weight-semibold">
|
||||||
|
{{ getStatusLabel(item.old_status) }} → {{ getStatusLabel(item.new_status) }}
|
||||||
|
</div>
|
||||||
|
<small v-if="item.note" class="text-muted d-block">{{ item.note }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MESSAGGI -->
|
||||||
|
<div class="card card-bg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-comment" size="sm" color="primary" class="me-2" />
|
||||||
|
Chat ({{ messages.length }})
|
||||||
|
</h6>
|
||||||
|
<MessageList :messages="messages" />
|
||||||
|
<MessageComposer @send="(msg) => { newMessage = msg; handleSendMessage() }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESTRA: Sidebar azioni -->
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
|
||||||
|
<!-- FEEDBACK (se risolta) -->
|
||||||
|
<div v-if="canFeedback && isOwner" class="card card-bg card-highlight mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-star-outline" size="sm" color="primary" class="me-2" />
|
||||||
|
Lascia un feedback
|
||||||
|
</h6>
|
||||||
|
<button
|
||||||
|
v-if="!isFeedbackOpen"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary w-100"
|
||||||
|
@click="isFeedbackOpen = true"
|
||||||
|
>
|
||||||
|
Valuta l'intervento
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Valutazione</label>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button v-for="n in 5" :key="n" type="button" class="btn btn-link p-0"
|
||||||
|
@click="feedbackRating = n" :aria-label="`${n} stelle`">
|
||||||
|
<BIIcon
|
||||||
|
:name="n <= feedbackRating ? 'it-star' : 'it-star-outline'"
|
||||||
|
size="lg"
|
||||||
|
:color="n <= feedbackRating ? 'warning' : 'primary'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea
|
||||||
|
v-model="feedbackComment"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Commento (opzionale)"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="button" class="btn btn-primary" @click="handleSubmitFeedback" :disabled="isSubmittingFeedback">
|
||||||
|
<span v-if="isSubmittingFeedback" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Invia
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-light" @click="isFeedbackOpen = false">
|
||||||
|
Cancella
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SEGNALA ABUSO -->
|
||||||
|
<div class="card card-bg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-flag" size="sm" color="danger" class="me-2" />
|
||||||
|
Segnala abuso
|
||||||
|
</h6>
|
||||||
|
<textarea
|
||||||
|
v-model="flagMessage"
|
||||||
|
class="form-control form-control-sm mb-2"
|
||||||
|
placeholder="Specifica il motivo dell'abuso..."
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-danger w-100"
|
||||||
|
@click="handleFlag"
|
||||||
|
:disabled="isFlagging || !flagMessage.trim()"
|
||||||
|
>
|
||||||
|
<BIIcon name="it-flag" size="xs" class="me-1" />
|
||||||
|
Invia segnalazione
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LOADING STATE -->
|
||||||
|
<div v-else class="container my-5 text-center">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Caricamento...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-highlight {
|
||||||
|
border-color: #006f9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marker {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #006f9d;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:not(:last-child) .timeline-marker::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 20px;
|
||||||
|
width: 2px;
|
||||||
|
height: calc(100% + 20px);
|
||||||
|
background-color: #006f9d;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratio {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-fit-cover {
|
||||||
|
object-fit: cover !important;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CreateReportRequest } from '~/types/api'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Crea Segnalazione - Visione' })
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { createReport, uploadMedia } = useReports()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
// Fetch categories
|
||||||
|
const { data: categoriesData } = await useAsyncData('categories', () =>
|
||||||
|
$fetch('/api/v1/admin/categories')
|
||||||
|
)
|
||||||
|
const categories = computed(() => (categoriesData.value as any)?.categories || [])
|
||||||
|
|
||||||
|
// 👇 Manteniamo una sola dichiarazione per queste variabili
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
// 👇 Manteniamo una sola funzione handleReportSubmit
|
||||||
|
const handleReportSubmit = async (reportData: CreateReportRequest, mediaFiles: File[]) => {
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Carica i file media se presenti
|
||||||
|
let uploadedMediaUrls: string[] = []
|
||||||
|
|
||||||
|
if (mediaFiles.length > 0) {
|
||||||
|
console.log('📸 Media files to upload:', mediaFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Crea la segnalazione
|
||||||
|
const response = await createReport({
|
||||||
|
...reportData,
|
||||||
|
media: uploadedMediaUrls, // Se l'API richiede stringhe pre-uploadate, va bene. Altrimenti passa array vuoto.
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Step 3: Se ha media, carica dopo la creazione usando l'ID
|
||||||
|
if (mediaFiles.length > 0 && response.report_id) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
mediaFiles.forEach((file) => {
|
||||||
|
formData.append('files', file)
|
||||||
|
})
|
||||||
|
|
||||||
|
await uploadMedia(response.report_id, formData)
|
||||||
|
console.log('✅ Media uploaded successfully')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('⚠️ Media upload failed (non-critical):', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Segnalazione creata:', response.report_id)
|
||||||
|
|
||||||
|
// Redirect al dettaglio della segnalazione
|
||||||
|
await router.push(`/reports/${response.report_id}`)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage.value = error.data?.message || 'Errore durante la creazione della segnalazione. Riprova.'
|
||||||
|
console.error('❌ Error creating report:', error)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- HERO -->
|
||||||
|
<div class="it-hero-wrapper it-primary-bg">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="it-hero-text-wrapper p-4">
|
||||||
|
<h1 class="text-white font-weight-bold">Segnala un problema</h1>
|
||||||
|
<p class="text-white mt-2 lead">
|
||||||
|
Aiutaci a migliorare la città condividendo i disservizi che noti
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO -->
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-8 offset-lg-2">
|
||||||
|
|
||||||
|
<!-- CALLOUT INFO -->
|
||||||
|
<div class="callout callout-highlight callout-primary mb-4">
|
||||||
|
<div class="callout-title">
|
||||||
|
<BIIcon name="it-info-circle" />
|
||||||
|
La tua segnalazione è anonima
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
I tuoi dati personali non saranno visibili pubblicamente. La segnalazione verrà elaborata dagli operatori comunali e potrai tracciare lo stato dell'intervento in tempo reale.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ERRORE GLOBALE -->
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
<button type="button" class="btn-close" @click="errorMessage = ''"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- INFO UTENTE -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-user" size="sm" color="primary" class="me-2" />
|
||||||
|
Stai segnalando come:
|
||||||
|
</h6>
|
||||||
|
<div class="small">
|
||||||
|
<strong>{{ user?.first_name }} {{ user?.last_name }}</strong><br>
|
||||||
|
<span class="text-muted">{{ user?.email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FORM PRINCIPALE -->
|
||||||
|
<ReportForm :categories="categories" :is-loading="isLoading" @submit="handleReportSubmit" />
|
||||||
|
|
||||||
|
<!-- NOTE LEGALI -->
|
||||||
|
<div class="alert alert-info mt-5" role="alert">
|
||||||
|
<small class="d-block">
|
||||||
|
Sottoponendo questa segnalazione accetti i nostri <NuxtLink to="/privacy" target="_blank">Termini di Servizio</NuxtLink>
|
||||||
|
e l'<NuxtLink to="/privacy" target="_blank">Informativa sulla Privacy</NuxtLink>.
|
||||||
|
Le segnalazioni con contenuti abusivi o offensivi verranno rimosse e l'utente potrebbe essere sanzionato.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.it-primary-bg {
|
||||||
|
background: linear-gradient(135deg, #006f9d 0%, #003d5c 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user