Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-06-13 11:42:47 +00:00 Unverified
20 changed files with 1626 additions and 1800 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ const { isAuthenticated, user, logout } = useAuth()
<div class="header-slim-right-zone" role="navigation"> <div class="header-slim-right-zone" role="navigation">
<!-- Utente Autenticato: Bottone Utente + Bottone Esci --> <!-- Utente Autenticato: Bottone Utente + Bottone Esci -->
<div v-if="isAuthenticated" class="d-flex align-items-center gap-2"> <div v-if="isAuthenticated" class="d-flex align-items-center gap-2">
<NuxtLink class="btn btn-primary btn-icon btn-full" to="/dashboard" <NuxtLink class="btn btn-primary btn-icon btn-full" to="/me"
data-element="personal-area-dashboard" data-focus-mouse="false"> data-element="personal-area-dashboard" data-focus-mouse="false">
<span class="rounded-icon" aria-hidden="true"> <span class="rounded-icon" aria-hidden="true">
<BIIcon name="it-user" color="black" /> <BIIcon name="it-user" color="black" />
+49 -43
View File
@@ -15,7 +15,7 @@ const props = withDefaults(defineProps<Props>(), {
modelValue: () => [], modelValue: () => [],
maxFiles: 5, maxFiles: 5,
acceptedTypes: 'image/*,video/*', acceptedTypes: 'image/*,video/*',
maxFileSize: 50, // 50MB maxFileSize: 50,
disabled: false disabled: false
}) })
@@ -36,16 +36,14 @@ const handleFilesSelected = (selectedFiles: FileList | null) => {
const newFiles = Array.from(selectedFiles) const newFiles = Array.from(selectedFiles)
// Validazione numero file
if (files.value.length + newFiles.length > props.maxFiles) { if (files.value.length + newFiles.length > props.maxFiles) {
error.value = `Massimo ${props.maxFiles} file consentiti` error.value = `Massimo ${props.maxFiles} file consentiti`
return return
} }
// Validazione dimensione
for (const file of newFiles) { for (const file of newFiles) {
if (file.size > props.maxFileSize! * 1024 * 1024) { if (file.size > props.maxFileSize! * 1024 * 1024) {
error.value = `File ${file.name} supera il limite di ${props.maxFileSize}MB` error.value = `Il file ${file.name} supera il limite di ${props.maxFileSize}MB`
return return
} }
} }
@@ -66,17 +64,16 @@ const formatFileSize = (bytes: number): string => {
return mb > 0 ? `${mb}MB` : 'Piccolo' return mb > 0 ? `${mb}MB` : 'Piccolo'
} }
const getMediaType = (file: File): string => { const getFileUrl = (file: File) => {
return file.type.startsWith('video/') ? 'Video' : 'Immagine' return URL.createObjectURL(file)
} }
</script> </script>
<template> <template>
<div class="media-uploader"> <div class="media-uploader">
<!-- Area trascinamento -->
<div <div
class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer" class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer position-relative"
:class="{ 'border-primary': isDragging, 'bg-light': !isDragging }" :class="{ 'border-primary bg-primary bg-opacity-10': isDragging, 'bg-light': !isDragging }"
@dragover.prevent="isDragging = true" @dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false" @dragleave.prevent="isDragging = false"
@drop.prevent="isDragging = false; handleFilesSelected(($event as DragEvent).dataTransfer?.files || null)" @drop.prevent="isDragging = false; handleFilesSelected(($event as DragEvent).dataTransfer?.files || null)"
@@ -84,54 +81,58 @@ const getMediaType = (file: File): string => {
> >
<BIIcon name="it-upload" size="xl" color="primary" class="mb-3" /> <BIIcon name="it-upload" size="xl" color="primary" class="mb-3" />
<div class="h6 font-weight-bold"> <div class="h6 font-weight-bold">
Trascina i file qui o <span class="text-primary">clicca per selezionare</span> Trascina i file qui o <span class="text-primary text-decoration-underline">clicca per selezionare</span>
</div> </div>
<small class="text-muted d-block"> <small class="text-muted d-block mt-2">
Massimo {{ maxFiles }} file, fino a {{ maxFileSize }}MB per file Massimo {{ maxFiles }} file ({{ maxFileSize }}MB max per file). Tipi: {{ acceptedTypes }}
</small> </small>
</div> </div>
<!-- Input nascosto -->
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file" type="file"
:multiple="props.maxFiles > 1" :multiple="maxFiles > 1"
:accept="acceptedTypes" :accept="acceptedTypes"
:disabled="disabled" :disabled="disabled"
style="display: none" class="d-none"
@change="handleFilesSelected(($event.target as HTMLInputElement).files)" @change="handleFilesSelected(($event.target as HTMLInputElement).files)"
/> />
<!-- Errori --> <div v-if="error" class="alert alert-danger mt-3 py-2 small" role="alert">
<div v-if="error" class="alert alert-danger mt-3 mb-0" role="alert">
{{ error }} {{ error }}
</div> </div>
<!-- Lista file --> <div v-if="files.length > 0" class="mt-4 row g-3">
<div v-if="files.length > 0" class="mt-4"> <div v-for="(file, idx) in files" :key="`file-${idx}`" class="col-6 col-md-4 col-lg-3">
<h6 class="font-weight-bold mb-3">File caricati ({{ files.length }}/{{ maxFiles }})</h6> <div class="card h-100 position-relative shadow-sm border-0">
<div class="list-group"> <div class="ratio ratio-1x1 bg-light rounded-top overflow-hidden">
<div v-for="(file, idx) in files" :key="`file-${idx}`" class="list-group-item d-flex justify-content-between align-items-center"> <img v-if="file.type.startsWith('image/')" :src="getFileUrl(file)" class="object-fit-cover" :alt="file.name" />
<div class="d-flex align-items-center gap-3"> <video v-else-if="file.type.startsWith('video/')" :src="getFileUrl(file)" class="object-fit-cover"></video>
<BIIcon <div v-else class="d-flex align-items-center justify-content-center bg-secondary bg-opacity-25">
:name="file.type.startsWith('video/') ? 'it-video' : 'it-image'" <BIIcon name="it-file" size="xl" color="secondary" />
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>
</div> </div>
<button <button
type="button" type="button"
class="btn btn-sm btn-link p-0" class="btn btn-danger btn-sm position-absolute top-0 end-0 m-1 rounded-circle p-1 d-flex align-items-center justify-content-center shadow"
@click="removeFile(idx)" style="width: 28px; height: 28px; z-index: 10;"
@click.stop="removeFile(idx)"
:disabled="disabled" :disabled="disabled"
:aria-label="`Rimuovi ${file.name}`" title="Rimuovi file"
> >
<BIIcon name="it-close-circle" /> <BIIcon name="it-close" size="sm" color="white" />
</button> </button>
<div class="card-body p-2 d-flex flex-column justify-content-between">
<div class="small text-truncate fw-semibold" :title="file.name">{{ file.name }}</div>
<div class="small text-muted d-flex justify-content-between align-items-center mt-1">
<span>{{ formatFileSize(file.size) }}</span>
<div class="progress flex-grow-1 ms-2" style="height: 4px;" title="Pronto per l'upload">
<div class="progress-bar bg-success w-100" role="progressbar"></div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -140,21 +141,26 @@ const getMediaType = (file: File): string => {
<style scoped> <style scoped>
.upload-area { .upload-area {
transition: all 0.3s ease; transition: all 0.2s ease-in-out;
cursor: pointer; }
.upload-area:hover:not(.border-primary) {
&:hover:not(.border-primary) { background-color: #f8f9fa !important;
background-color: rgba(0, 111, 157, 0.05); border-color: #0066cc !important;
}
} }
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
.border-dashed {
border-style: dashed !important;
}
.border-2 { .border-2 {
border-width: 2px !important; border-width: 2px !important;
} }
.object-fit-cover {
object-fit: cover;
width: 100%;
height: 100%;
}
</style> </style>
+53 -24
View File
@@ -1,39 +1,81 @@
<script setup lang="ts"> <script setup lang="ts">
interface Emits { interface Emits {
(e: 'send', message: string): void (e: 'send', message: string, files: File[]): void
} }
// Unica dichiarazione di defineEmits
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const message = ref('') const message = ref('')
const isSubmitting = ref(false) const isSubmitting = ref(false)
const files = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement>()
const handleSubmit = () => { const handleSubmit = () => {
if (message.value.trim() === '') return if (message.value.trim() === '' && files.value.length === 0) return
emit('send', message.value) emit('send', message.value, [...files.value])
message.value = '' message.value = ''
files.value = []
} }
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
// Ctrl/Cmd + Enter per inviare
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
// Previene l'andata a capo predefinita della textarea
e.preventDefault() e.preventDefault()
handleSubmit() handleSubmit()
} }
} }
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files) {
const newFiles = Array.from(target.files)
files.value = [...files.value, ...newFiles].slice(0, 5) // max 5
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const removeFile = (idx: number) => {
files.value.splice(idx, 1)
}
</script> </script>
<template> <template>
<div class="message-composer mt-4 border-top pt-3"> <div class="message-composer mt-4 border-top pt-3">
<!-- Non serve passare emit qui -->
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div class="input-group">
<!-- Preview Allegati -->
<div v-if="files.length > 0" class="mb-3 d-flex flex-wrap gap-2">
<div v-for="(file, idx) in files" :key="idx" class="badge bg-light text-dark border p-2 d-flex align-items-center gap-2">
<BIIcon name="it-clip" size="xs" />
<span class="text-truncate" style="max-width: 150px;">{{ file.name }}</span>
<button type="button" class="btn-close ms-2" style="font-size: 0.5rem;" @click="removeFile(idx)" aria-label="Rimuovi allegato"></button>
</div>
</div>
<div class="input-group align-items-end">
<button
type="button"
class="btn btn-outline-secondary me-2 rounded"
title="Allega file (max 5)"
@click="fileInputRef?.click()"
:disabled="isSubmitting || files.length >= 5"
>
<BIIcon name="it-clip" size="sm" />
</button>
<input
ref="fileInputRef"
type="file"
multiple
class="d-none"
@change="handleFileSelect"
accept="image/*,video/*,.pdf,.doc,.docx"
/>
<textarea <textarea
v-model="message" v-model="message"
class="form-control" class="form-control rounded"
placeholder="Scrivi un messaggio... (Ctrl+Enter per inviare)" placeholder="Scrivi un messaggio... (Ctrl+Enter per inviare)"
rows="2" rows="2"
:disabled="isSubmitting" :disabled="isSubmitting"
@@ -41,8 +83,8 @@ const handleKeydown = (e: KeyboardEvent) => {
/> />
<button <button
type="submit" type="submit"
class="btn btn-primary" class="btn btn-primary ms-2 rounded"
:disabled="isSubmitting || message.trim() === ''" :disabled="isSubmitting || (message.trim() === '' && files.length === 0)"
> >
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span> <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" /> <BIIcon v-else name="it-arrow-right" size="sm" color="white" class="me-1" />
@@ -67,17 +109,4 @@ textarea {
resize: vertical; resize: vertical;
font-family: inherit; font-family: inherit;
} }
.input-group {
display: flex;
gap: 0.5rem;
}
textarea {
flex: 1;
}
button {
flex-shrink: 0;
}
</style> </style>
+6 -1
View File
@@ -43,7 +43,12 @@ const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userI
</div> </div>
<div v-else class="messages-container"> <div v-else class="messages-container">
<div v-for="msg in messages" :key="msg.id" class="message-bubble mb-3"> <div
v-for="msg in messages"
:key="msg.id"
class="message-bubble mb-3 d-flex"
:class="{ 'justify-content-end': isCurrentUser(msg.user_id), 'justify-content-start': !isCurrentUser(msg.user_id) }"
>
<div <div
class="message-bubble-content p-3 rounded-3" class="message-bubble-content p-3 rounded-3"
:class="{ :class="{
+115 -28
View File
@@ -1,5 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Category, CreateReportRequest } from '~/types/api' import type { Category, CreateReportRequest } from '~/types/api'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
// Fix per le icone di Leaflet in Nuxt/Vite
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/[email protected]/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
L.Marker.prototype.options.icon = DefaultIcon;
interface Props { interface Props {
isLoading?: boolean isLoading?: boolean
@@ -30,6 +45,66 @@ const errors = ref<Record<string, string>>({})
const successMessage = ref(false) const successMessage = ref(false)
const isGettingLocation = ref(false) const isGettingLocation = ref(false)
// Map variables
const mapContainer = ref<HTMLElement | null>(null)
let map: L.Map | null = null
let marker: L.Marker | null = null
const initMap = () => {
if (!mapContainer.value || map) return
// Centro Italia default
const defaultLat = 41.8719
const defaultLng = 12.5674
const zoom = 5
map = L.map(mapContainer.value).setView([defaultLat, defaultLng], zoom)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map)
map.on('click', (e: L.LeafletMouseEvent) => {
updateLocation(e.latlng.lat, e.latlng.lng)
})
// Se c'è già una posizione, imposta il marker
if (form.value.latitude && form.value.longitude) {
updateLocation(form.value.latitude, form.value.longitude, true)
}
}
const updateLocation = (lat: number, lng: number, zoomTo = false) => {
form.value.latitude = Number(lat.toFixed(6))
form.value.longitude = Number(lng.toFixed(6))
if (map) {
if (!marker) {
marker = L.marker([lat, lng]).addTo(map)
} else {
marker.setLatLng([lat, lng])
}
if (zoomTo) {
map.setView([lat, lng], 16)
}
}
if (errors.value.location) {
errors.value.location = ''
}
}
onMounted(() => {
// Map logic is handled by ClientOnly placeholder if needed, but we initialize here
})
onUnmounted(() => {
if (map) {
map.remove()
map = null
}
})
const validateForm = (): boolean => { const validateForm = (): boolean => {
errors.value = {} errors.value = {}
@@ -37,7 +112,7 @@ const validateForm = (): boolean => {
errors.value.category_id = 'Seleziona una categoria' errors.value.category_id = 'Seleziona una categoria'
} }
if (!form.value.latitude || !form.value.longitude) { if (!form.value.latitude || !form.value.longitude) {
errors.value.location = 'Imposta la posizione' errors.value.location = 'Imposta la posizione sulla mappa'
} }
if (!form.value.address?.trim()) { if (!form.value.address?.trim()) {
errors.value.address = 'Inserisci l\'indirizzo' errors.value.address = 'Inserisci l\'indirizzo'
@@ -63,13 +138,9 @@ const getGeolocation = async () => {
navigator.geolocation.getCurrentPosition(resolve, reject) navigator.geolocation.getCurrentPosition(resolve, reject)
}) })
form.value.latitude = Number(position.coords.latitude.toFixed(6)) updateLocation(position.coords.latitude, position.coords.longitude, true)
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) { } catch (error: any) {
errors.value.location = 'Impossibile ottenere la posizione. Inserisci manualmente.' errors.value.location = 'Impossibile ottenere la posizione. Clicca sulla mappa per inserirla manualmente.'
} finally { } finally {
isGettingLocation.value = false isGettingLocation.value = false
} }
@@ -99,26 +170,43 @@ const resetForm = () => {
} }
media.value = [] media.value = []
errors.value = {} errors.value = {}
if (marker && map) {
map.removeLayer(marker)
marker = null
map.setView([41.8719, 12.5674], 5)
}
} }
// Osserva cambio categoria
watch(() => form.value.category_id, () => { watch(() => form.value.category_id, () => {
if (errors.value.category_id) { if (errors.value.category_id) {
errors.value.category_id = '' errors.value.category_id = ''
} }
}) })
// Sincronizza input manuali con la mappa
watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => {
if (newLat && newLng && map) {
if (!marker || marker.getLatLng().lat !== newLat || marker.getLatLng().lng !== newLng) {
if (!marker) {
marker = L.marker([newLat, newLng]).addTo(map)
} else {
marker.setLatLng([newLat, newLng])
}
map.setView([newLat, newLng], 16)
}
}
})
</script> </script>
<template> <template>
<div class="report-form"> <div class="report-form">
<!-- Successo -->
<div v-if="successMessage" class="alert alert-success alert-dismissible fade show" role="alert"> <div v-if="successMessage" class="alert alert-success alert-dismissible fade show" role="alert">
Segnalazione creata con successo! Reindirizzamento... Segnalazione creata con successo! Reindirizzamento...
<button type="button" class="btn-close" @click="successMessage = false"></button> <button type="button" class="btn-close" @click="successMessage = false"></button>
</div> </div>
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<!-- CATEGORIA -->
<div class="mb-4"> <div class="mb-4">
<label for="category" class="form-label fw-bold"> <label for="category" class="form-label fw-bold">
<BIIcon name="it-list" size="sm" color="primary" class="me-2" /> <BIIcon name="it-list" size="sm" color="primary" class="me-2" />
@@ -141,20 +229,12 @@ watch(() => form.value.category_id, () => {
</div> </div>
</div> </div>
<!-- GEOLOCALIZZAZIONE -->
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-bold"> <label class="form-label fw-bold">
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" /> <BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
Posizione * Posizione *
</label> </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 <button
type="button" type="button"
class="btn btn-outline-primary w-100 mb-3" class="btn btn-outline-primary w-100 mb-3"
@@ -166,9 +246,14 @@ watch(() => form.value.category_id, () => {
{{ isGettingLocation ? 'Localizzazione in corso...' : 'Usa posizione attuale' }} {{ isGettingLocation ? 'Localizzazione in corso...' : 'Usa posizione attuale' }}
</button> </button>
<div class="alert alert-warning mb-3 small" role="alert"> <ClientOnly>
Se la geolocalizzazione non funziona, inserisci manualmente coordinate o indirizzo approssimativo. <div
</div> ref="mapContainer"
class="map-container mb-3 border rounded"
:class="{ 'border-danger': errors.location }"
@vue:mounted="initMap"
></div>
</ClientOnly>
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
@@ -176,7 +261,7 @@ watch(() => form.value.category_id, () => {
v-model.number="form.latitude" v-model.number="form.latitude"
type="number" type="number"
step="0.000001" step="0.000001"
class="form-control" class="form-control form-control-sm"
:class="{ 'is-invalid': errors.location }" :class="{ 'is-invalid': errors.location }"
placeholder="Latitudine (es. 45.5)" placeholder="Latitudine (es. 45.5)"
required required
@@ -187,19 +272,18 @@ watch(() => form.value.category_id, () => {
v-model.number="form.longitude" v-model.number="form.longitude"
type="number" type="number"
step="0.000001" step="0.000001"
class="form-control" class="form-control form-control-sm"
:class="{ 'is-invalid': errors.location }" :class="{ 'is-invalid': errors.location }"
placeholder="Longitudine (es. 10.2)" placeholder="Longitudine (es. 10.2)"
required required
/> />
</div> </div>
</div> </div>
<div v-if="errors.location" class="invalid-feedback d-block"> <div v-if="errors.location" class="invalid-feedback d-block mt-0 mb-3">
{{ errors.location }} {{ errors.location }}
</div> </div>
</div> </div>
<!-- INDIRIZZO -->
<div class="mb-4"> <div class="mb-4">
<label for="address" class="form-label fw-bold"> <label for="address" class="form-label fw-bold">
<BIIcon name="it-home" size="sm" color="primary" class="me-2" /> <BIIcon name="it-home" size="sm" color="primary" class="me-2" />
@@ -219,7 +303,6 @@ watch(() => form.value.category_id, () => {
</div> </div>
</div> </div>
<!-- DESCRIZIONE -->
<div class="mb-4"> <div class="mb-4">
<label for="description" class="form-label fw-bold"> <label for="description" class="form-label fw-bold">
<BIIcon name="it-write" size="sm" color="primary" class="me-2" /> <BIIcon name="it-write" size="sm" color="primary" class="me-2" />
@@ -242,7 +325,6 @@ watch(() => form.value.category_id, () => {
</div> </div>
</div> </div>
<!-- MEDIA -->
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-bold"> <label class="form-label fw-bold">
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" /> <BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
@@ -251,7 +333,6 @@ watch(() => form.value.category_id, () => {
<MediaUploader v-model="media" :max-files="5" :max-file-size="50" /> <MediaUploader v-model="media" :max-files="5" :max-file-size="50" />
</div> </div>
<!-- BOTTONI -->
<div class="d-grid gap-2 d-md-flex justify-content-md-between"> <div class="d-grid gap-2 d-md-flex justify-content-md-between">
<button <button
type="button" type="button"
@@ -281,6 +362,12 @@ watch(() => form.value.category_id, () => {
padding: 2rem; padding: 2rem;
} }
.map-container {
height: 300px;
width: 100%;
z-index: 1; /* Fix for Bootstrap elements overlapping map */
}
textarea { textarea {
font-family: inherit; font-family: inherit;
resize: vertical; resize: vertical;
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { ReportStatus } from '~/types/api'
interface Props {
status: ReportStatus
}
const props = defineProps<Props>()
const badgeConfig = computed(() => {
const config: Record<ReportStatus, { color: string, text: string, icon: string }> = {
OPEN: { color: 'info', text: 'Aperta', icon: 'it-info-circle' },
UNDER_REVIEW: { color: 'warning', text: 'In Revisione', icon: 'it-clock' },
ASSIGNED: { color: 'primary', text: 'Assegnata', icon: 'it-user' },
IN_PROGRESS: { color: 'primary', text: 'In Lavorazione', icon: 'it-settings' },
RESOLVED: { color: 'success', text: 'Risolta', icon: 'it-check-circle' },
REJECTED: { color: 'danger', text: 'Rifiutata', icon: 'it-close-circle' },
CLOSED: { color: 'secondary', text: 'Chiusa', icon: 'it-locked' }
}
return config[props.status] || { color: 'secondary', text: props.status, icon: 'it-info-circle' }
})
</script>
<template>
<span class="badge badge-pill rounded-pill d-inline-flex align-items-center gap-1" :class="`bg-${badgeConfig.color}`">
<BIIcon :name="badgeConfig.icon" size="xs" color="white" />
{{ badgeConfig.text }}
</span>
</template>
<style scoped>
.badge {
font-weight: 600;
padding: 0.35em 0.65em;
}
</style>
+8 -23
View File
@@ -66,22 +66,6 @@ const handlePageChange = (newPage: number) => {
} }
// Helpers grafici // Helpers grafici
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'
}
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
}
const formatDate = (dateString?: string): string => { const formatDate = (dateString?: string): string => {
if (!dateString) return '' if (!dateString) return ''
return new Date(dateString).toLocaleDateString('it-IT', { return new Date(dateString).toLocaleDateString('it-IT', {
@@ -220,9 +204,7 @@ const formatDate = (dateString?: string): string => {
</span> </span>
</td> </td>
<td class="small"> <td class="small">
<span class="badge" :class="`bg-${getStatusColor(report.status)}`"> <StatusBadge :status="report.status" />
{{ getStatusLabel(report.status) }}
</span>
</td> </td>
<td class="small text-muted">{{ formatDate(report.created_at) }}</td> <td class="small text-muted">{{ formatDate(report.created_at) }}</td>
<td class="text-center"> <td class="text-center">
@@ -235,10 +217,13 @@ const formatDate = (dateString?: string): string => {
</table> </table>
</div> </div>
<!-- Paginazione Client-Side (Opzionale: implementare se hai molti dati) --> <Pagination
<div v-if="totalFiltered > perPage" class="d-flex justify-content-center mt-4"> v-if="totalFiltered > perPage"
<!-- Qui potresti inserire un componente Pagination se ne hai uno --> :current-page="page"
</div> :per-page="perPage"
:total-items="totalFiltered"
@page-change="handlePageChange"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -50,7 +50,7 @@ const handleTakeReport = async (id: number) => {
> >
<div> <div>
<h6 class="mb-1">{{ report.address }}</h6> <h6 class="mb-1">{{ report.address }}</h6>
<span class="badge bg-warning text-dark">{{ report.status }}</span> <StatusBadge :status="report.status" />
</div> </div>
<BIIcon name="it-chevron-right" size="sm" color="primary" /> <BIIcon name="it-chevron-right" size="sm" color="primary" />
</NuxtLink> </NuxtLink>
+3 -2
View File
@@ -2,7 +2,8 @@ import { defu } from 'defu'
export const useApiFetch = <T>(request: string, options: Parameters<typeof $fetch>[1] = {}) => { export const useApiFetch = <T>(request: string, options: Parameters<typeof $fetch>[1] = {}) => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const tokenCookie = useCookie('auth_token') const nuxtApp = useNuxtApp()
const tokenCookie = useCookie('auth_token', { path: '/' })
const tokenState = useState<string | null>('auth_token_state') const tokenState = useState<string | null>('auth_token_state')
const tokenValue = tokenState.value ?? tokenCookie.value const tokenValue = tokenState.value ?? tokenCookie.value
@@ -38,7 +39,7 @@ export const useApiFetch = <T>(request: string, options: Parameters<typeof $fetc
} }
try { try {
navigateTo('/auth/login') nuxtApp.runWithContext(() => navigateTo('/auth/login'))
} catch (e) { } catch (e) {
console.error('[API] Error navigating to login:', e) console.error('[API] Error navigating to login:', e)
} }
+11 -8
View File
@@ -8,9 +8,13 @@ import type {
} from "~/types/api"; } from "~/types/api";
export const useAuth = () => { export const useAuth = () => {
const token = useCookie('auth_token', { const route = useRoute()
const nuxtApp = useNuxtApp()
const token = useCookie<string | null>('auth_token', {
maxAge: 60 * 60 * 12, maxAge: 60 * 60 * 12,
sameSite: 'lax', sameSite: 'lax',
path: '/',
watch: true watch: true
}) })
@@ -25,7 +29,7 @@ export const useAuth = () => {
tokenState.value = newVal || null tokenState.value = newVal || null
}) })
const syncUser = async () => { const syncUser = async (redirectOnSuccess = false) => {
const tokenVal = tokenState.value || token.value const tokenVal = tokenState.value || token.value
if (!tokenVal || isSyncingUser.value) return if (!tokenVal || isSyncingUser.value) return
@@ -36,12 +40,11 @@ export const useAuth = () => {
console.log('[Auth] User synced successfully') console.log('[Auth] User synced successfully')
user.value = res.user user.value = res.user
const route = useRoute()
if (res.user.must_change_password && route.path !== '/auth/change-password') { if (res.user.must_change_password && route.path !== '/auth/change-password') {
console.log('[Auth] User must change password, redirecting...') console.log('[Auth] User must change password, redirecting...')
await navigateTo('/auth/change-password') await nuxtApp.runWithContext(() => navigateTo('/auth/change-password'))
} else { } else if (redirectOnSuccess && route.path.startsWith('/auth')) {
await navigateTo('/dashboard') await nuxtApp.runWithContext(() => navigateTo('/me'))
} }
} }
} catch (error) { } catch (error) {
@@ -55,14 +58,14 @@ export const useAuth = () => {
const handleLoginSuccess = async (jwtToken: string) => { const handleLoginSuccess = async (jwtToken: string) => {
token.value = jwtToken token.value = jwtToken
tokenState.value = jwtToken tokenState.value = jwtToken
await syncUser() await syncUser(true)
} }
const logout = () => { const logout = () => {
token.value = null token.value = null
tokenState.value = null tokenState.value = null
user.value = null user.value = null
navigateTo('/auth/login') nuxtApp.runWithContext(() => navigateTo('/auth/login'))
} }
const register = (body: RegisterCitizenRequest) => const register = (body: RegisterCitizenRequest) =>
+1 -3
View File
@@ -1,7 +1,6 @@
export default defineNuxtRouteMiddleware(async () => { export default defineNuxtRouteMiddleware(async () => {
const auth = useAuth() const auth = useAuth()
// Se c'è un token ma l'utente non è ancora caricato e non stiamo già sincronizzando, sincronizza
if ((auth.token.value || auth.tokenState.value) && !auth.user.value && !auth.isSyncingUser.value) { if ((auth.token.value || auth.tokenState.value) && !auth.user.value && !auth.isSyncingUser.value) {
try { try {
await auth.syncUser() await auth.syncUser()
@@ -10,8 +9,7 @@ export default defineNuxtRouteMiddleware(async () => {
} }
} }
// Se autenticato, redirige al dashboard
if (auth.isAuthenticated.value && auth.user.value) { if (auth.isAuthenticated.value && auth.user.value) {
return navigateTo('/dashboard') return navigateTo('/me')
} }
}) })
+1 -1
View File
@@ -45,7 +45,7 @@ const handleSubmit = async () => {
if (res.ok) { if (res.ok) {
await syncUser() await syncUser()
await router.push('/dashboard') await router.push('/me')
} }
} catch (error: any) { } catch (error: any) {
errorMessage.value = error.data?.message || 'Errore. Verifica la password attuale.' errorMessage.value = error.data?.message || 'Errore. Verifica la password attuale.'
-1
View File
@@ -41,7 +41,6 @@ const handleVerifyOtp = async () => {
try { try {
const res = await verifyOtp({ email: email.value, code: otpCode.value }) const res = await verifyOtp({ email: email.value, code: otpCode.value })
await handleLoginSuccess(res.token) await handleLoginSuccess(res.token)
router.push('/dashboard')
} catch (err: any) { } catch (err: any) {
errorMessage.value = 'Codice OTP non valido o scaduto.' errorMessage.value = 'Codice OTP non valido o scaduto.'
} finally { } finally {
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import type { Report } from '~/types/api'
definePageMeta({
middleware: 'auth',
auth: { role: 'OPERATOR' }
})
useHead({ title: 'Mie Assegnazioni - Operatore' })
const { getAssignedReports } = useOperator()
const page = ref(1)
const perPage = ref(10)
const { data: reportsRes, pending } = useAsyncData(
'operator-assigned-reports',
() => getAssignedReports({ page: page.value, per_page: perPage.value }),
{ watch: [page] }
)
const formatDate = (dateString: string) => new Date(dateString).toLocaleDateString('it-IT')
</script>
<template>
<div class="container my-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 font-weight-bold mb-0">Le Mie Assegnazioni</h1>
<div>
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink>
<NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Coda Open</NuxtLink>
</div>
</div>
<div v-if="pending" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
<div v-else-if="!reportsRes?.reports || reportsRes.reports.length === 0" class="alert alert-info">
Non hai segnalazioni in carico al momento.
</div>
<div v-else>
<div class="table-responsive">
<table class="table table-hover border-bottom">
<thead class="table-light">
<tr>
<th scope="col" class="small">#ID</th>
<th scope="col" class="small">Categoria</th>
<th scope="col" class="small">Indirizzo</th>
<th scope="col" class="small">Stato</th>
<th scope="col" class="small">Creata il</th>
<th scope="col" class="text-center small">Azioni</th>
</tr>
</thead>
<tbody>
<tr v-for="report in reportsRes.reports" :key="report.id" class="align-middle">
<td class="small font-weight-semibold">#{{ report.id }}</td>
<td class="small">{{ report.category_name }}</td>
<td class="small">{{ report.address }}</td>
<td class="small">
<StatusBadge :status="report.status" />
</td>
<td class="small text-muted">{{ formatDate(report.created_at) }}</td>
<td class="text-center">
<NuxtLink :to="`/operator/reports/${report.id}`" class="btn btn-sm btn-outline-primary">
Gestisci
</NuxtLink>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
v-if="reportsRes.total > perPage"
:current-page="page"
:per-page="perPage"
:total-items="reportsRes.total"
@page-change="page = $event"
/>
</div>
</div>
</template>
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import type { Report } from '~/types/api'
definePageMeta({
middleware: 'auth',
auth: { role: 'OPERATOR' }
})
useHead({ title: 'Segnalazioni Aperte - Operatore' })
const { getOpenReports, takeReport } = useOperator()
const page = ref(1)
const perPage = ref(10)
const { data: reportsRes, pending, refresh } = useAsyncData(
'operator-open-reports',
() => getOpenReports({ page: page.value, per_page: perPage.value }),
{ watch: [page] }
)
const handleTakeReport = async (id: number) => {
try {
const res = await takeReport(id)
if (res.ok) {
// Optimistic update or refresh
await refresh()
}
} catch (err) {
console.error('Failed to take report', err)
}
}
const formatDate = (dateString: string) => new Date(dateString).toLocaleDateString('it-IT')
</script>
<template>
<div class="container my-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 font-weight-bold mb-0">Segnalazioni da prendere in carico (OPEN)</h1>
<div>
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink>
<NuxtLink to="/operator/assigned" class="btn btn-outline-primary btn-sm">Le mie assegnazioni</NuxtLink>
</div>
</div>
<div v-if="pending" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
<div v-else-if="!reportsRes?.reports || reportsRes.reports.length === 0" class="alert alert-info">
Non ci sono segnalazioni aperte in questo momento.
</div>
<div v-else>
<div class="table-responsive">
<table class="table table-hover border-bottom">
<thead class="table-light">
<tr>
<th scope="col" class="small">#ID</th>
<th scope="col" class="small">Categoria</th>
<th scope="col" class="small">Indirizzo</th>
<th scope="col" class="small">Stato</th>
<th scope="col" class="small">Creata il</th>
<th scope="col" class="text-center small">Azioni</th>
</tr>
</thead>
<tbody>
<tr v-for="report in reportsRes.reports" :key="report.id" class="align-middle">
<td class="small font-weight-semibold">#{{ report.id }}</td>
<td class="small">{{ report.category_name }}</td>
<td class="small">{{ report.address }}</td>
<td class="small">
<StatusBadge :status="report.status" />
</td>
<td class="small text-muted">{{ formatDate(report.created_at) }}</td>
<td class="text-center">
<button class="btn btn-primary btn-sm" @click="handleTakeReport(report.id)">
Prendi in carico
</button>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
v-if="reportsRes.total > perPage"
:current-page="page"
:per-page="perPage"
:total-items="reportsRes.total"
@page-change="page = $event"
/>
</div>
</div>
</template>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import type { Report } from '~/types/api'
definePageMeta({
middleware: 'auth',
auth: { role: 'OPERATOR' }
})
useHead({ title: 'Gestite da Altri - Operatore' })
const { getOtherOperatorReports } = useOperator()
const page = ref(1)
const perPage = ref(10)
const { data: reportsRes, pending } = useAsyncData(
'operator-other-reports',
() => getOtherOperatorReports({ page: page.value, per_page: perPage.value }),
{ watch: [page] }
)
const formatDate = (dateString: string) => new Date(dateString).toLocaleDateString('it-IT')
</script>
<template>
<div class="container my-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 font-weight-bold mb-0">Gestite da altri Operatori</h1>
<div>
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink>
<NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Coda Open</NuxtLink>
</div>
</div>
<div v-if="pending" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
<div v-else-if="!reportsRes?.reports || reportsRes.reports.length === 0" class="alert alert-info">
Non ci sono segnalazioni gestite da altri.
</div>
<div v-else>
<div class="table-responsive">
<table class="table table-hover border-bottom">
<thead class="table-light">
<tr>
<th scope="col" class="small">#ID</th>
<th scope="col" class="small">Categoria</th>
<th scope="col" class="small">Indirizzo</th>
<th scope="col" class="small">Stato</th>
<th scope="col" class="small">Creata il</th>
<th scope="col" class="text-center small">Dettagli</th>
</tr>
</thead>
<tbody>
<tr v-for="report in reportsRes.reports" :key="report.id" class="align-middle">
<td class="small font-weight-semibold">#{{ report.id }}</td>
<td class="small">{{ report.category_name }}</td>
<td class="small">{{ report.address }}</td>
<td class="small">
<StatusBadge :status="report.status" />
</td>
<td class="small text-muted">{{ formatDate(report.created_at) }}</td>
<td class="text-center">
<NuxtLink :to="`/operator/reports/${report.id}`" class="btn btn-sm btn-outline-secondary">
Vedi
</NuxtLink>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
v-if="reportsRes.total > perPage"
:current-page="page"
:per-page="perPage"
:total-items="reportsRes.total"
@page-change="page = $event"
/>
</div>
</div>
</template>
+292
View File
@@ -0,0 +1,292 @@
<script setup lang="ts">
import type { ReportStatus } from '~/types/api'
definePageMeta({
middleware: 'auth',
auth: { role: 'OPERATOR' }
})
useHead({ title: 'Gestione Segnalazione - Operatore' })
const route = useRoute()
const runtimeConfig = useRuntimeConfig()
const reportId = Number(route.params.id)
if (!reportId) {
navigateTo('/operator')
}
const { getReportDetail, getMessages, addMessage } = useReports()
const { getInterventionSummary, updateReportStatus, assignTeam } = useOperator()
// Dati principali
const { data: reportData, refresh: refreshReport } = await useAsyncData(`op-report-${reportId}`, () => getReportDetail(reportId))
const { data: messagesData, refresh: refreshMessages } = await useAsyncData(`op-messages-${reportId}`, () => getMessages(reportId))
const { data: summaryData, refresh: refreshSummary } = await useAsyncData(`op-summary-${reportId}`, () => getInterventionSummary(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 summary = computed(() => (summaryData.value as any)?.summary)
// Form Cambia Stato
const newStatus = ref<ReportStatus>('IN_PROGRESS')
const statusNote = ref('')
const isUpdatingStatus = ref(false)
// Form Assegna Squadra
const isAssignModalOpen = ref(false)
const teamName = ref('')
const assignNote = ref('')
const estimatedDate = ref('')
const isAssigning = ref(false)
// Messaggi
const isSubmittingMessage = ref(false)
const availableStatuses: { value: ReportStatus, label: string }[] = [
{ value: 'OPEN', label: 'Aperta' },
{ value: 'UNDER_REVIEW', label: 'In Revisione' },
{ value: 'ASSIGNED', label: 'Assegnata' },
{ value: 'IN_PROGRESS', label: 'In Lavorazione' },
{ value: 'RESOLVED', label: 'Risolta' },
{ value: 'REJECTED', label: 'Rifiutata' },
{ value: 'CLOSED', label: 'Chiusa' }
]
// Inizializza lo stato nel form
watch(() => report.value?.status, (val) => {
if (val) newStatus.value = val as ReportStatus
}, { immediate: true })
const handleStatusUpdate = async () => {
isUpdatingStatus.value = true
try {
const res = await updateReportStatus(reportId, {
status: newStatus.value,
note: statusNote.value || undefined
})
if (res.ok) {
statusNote.value = ''
await Promise.all([refreshReport(), refreshSummary()])
}
} catch (e) {
console.error('Failed to update status', e)
} finally {
isUpdatingStatus.value = false
}
}
const handleAssignTeam = async () => {
if (!teamName.value) return
isAssigning.value = true
try {
const res = await assignTeam(reportId, {
assigned_to_team: teamName.value,
estimated_resolution_date: estimatedDate.value || undefined,
internal_notes: assignNote.value || undefined
})
if (res.ok) {
isAssignModalOpen.value = false
teamName.value = ''
assignNote.value = ''
estimatedDate.value = ''
await Promise.all([refreshReport(), refreshSummary()])
}
} catch (e) {
console.error('Failed to assign team', e)
} finally {
isAssigning.value = false
}
}
const handleSendMessage = async (msgText: string, files: File[]) => {
if (!msgText.trim() && files.length === 0) return
isSubmittingMessage.value = true
try {
await addMessage(reportId, { body: msgText })
await refreshMessages()
} catch (e) {
console.error('Failed to send message', e)
} finally {
isSubmittingMessage.value = false
}
}
const formatDate = (dateString: string) => {
if (!dateString) return '-'
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">
<div class="bg-light border-bottom py-3">
<div class="container d-flex justify-content-between align-items-center">
<div>
<NuxtLink to="/operator/assigned" class="btn btn-sm btn-link text-decoration-none ps-0">
&larr; Torna alle assegnazioni
</NuxtLink>
<h1 class="h4 font-weight-bold mb-1">Intervento #{{ reportId }}</h1>
<div class="d-flex align-items-center gap-2">
<StatusBadge :status="report.status" />
<small class="text-muted">{{ report.category_name }}</small>
</div>
</div>
</div>
</div>
<div class="container my-5">
<div class="row">
<!-- Sinistra: Dettagli -->
<div class="col-12 col-lg-7 mb-4">
<div class="card card-bg mb-4">
<div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Dettagli Cittadino</h5>
<p class="mb-1"><strong>Indirizzo:</strong> {{ report.address }}</p>
<p class="mb-3"><strong>Posizione:</strong> {{ report.latitude }}, {{ report.longitude }}</p>
<h6 class="font-weight-bold">Descrizione</h6>
<p class="text-break bg-white p-3 border rounded">{{ report.description }}</p>
<div v-if="media.length > 0">
<h6 class="font-weight-bold mt-4 mb-3">Allegati del cittadino</h6>
<div class="row g-2">
<div v-for="m in media" :key="m.id" class="col-4 col-md-3">
<img v-if="m.media_kind === 'IMAGE'" :src="runtimeConfig.public.api + m.url" class="img-fluid rounded object-fit-cover shadow-sm" style="aspect-ratio: 1;" />
</div>
</div>
</div>
</div>
</div>
<div class="card card-bg mb-4">
<div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Comunicazioni con il Cittadino</h5>
<MessageList :messages="messages" />
<MessageComposer @send="handleSendMessage" />
</div>
</div>
</div>
<!-- Destra: Strumenti Operatore -->
<div class="col-12 col-lg-5">
<!-- Summary Box -->
<div class="card border-primary mb-4 shadow-sm" v-if="summary">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">Riepilogo Intervento</h5>
</div>
<div class="card-body">
<p class="mb-2"><strong>Operatore Assegnato:</strong> {{ summary.operator_id ? `#${summary.operator_id}` : 'Nessuno' }}</p>
<p class="mb-2"><strong>Squadra Assegnata:</strong> <span class="badge bg-secondary">{{ summary.assigned_to_team || 'Nessuna' }}</span></p>
<p class="mb-2"><strong>Data Prevista:</strong> {{ summary.estimated_resolution_date ? formatDate(summary.estimated_resolution_date) : 'Non impostata' }}</p>
<p class="mb-0"><strong>Note Interne:</strong></p>
<p class="small text-muted bg-light p-2 rounded border mt-1 mb-0">{{ summary.internal_notes || 'Nessuna nota interna' }}</p>
</div>
<div class="card-footer bg-white border-top-0 pt-0">
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true">
Gestisci Squadra e Note
</button>
</div>
</div>
<!-- Modifica Stato -->
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Aggiorna Stato</h5>
<form @submit.prevent="handleStatusUpdate">
<div class="mb-3">
<label class="form-label">Nuovo Stato</label>
<select class="form-select" v-model="newStatus">
<option v-for="st in availableStatuses" :key="st.value" :value="st.value">
{{ st.label }}
</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Nota (visibile nella cronologia)</label>
<textarea class="form-control" v-model="statusNote" rows="2" placeholder="Motivazione o dettaglio..."></textarea>
</div>
<button type="submit" class="btn btn-primary w-100" :disabled="isUpdatingStatus || newStatus === report.status">
<span v-if="isUpdatingStatus" class="spinner-border spinner-border-sm me-2"></span>
Aggiorna Stato
</button>
</form>
</div>
</div>
<!-- Cronologia -->
<div class="card card-bg">
<div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Cronologia Eventi</h5>
<div v-if="statusHistory.length === 0" class="small text-muted">Nessun evento registrato.</div>
<ul class="list-unstyled mb-0 ms-2">
<li v-for="item in statusHistory" :key="item.id" class="mb-3 border-start border-primary border-3 ps-3">
<small class="text-muted d-block">{{ formatDate(item.created_at) }}</small>
<div class="d-flex align-items-center gap-2 mt-1">
<StatusBadge :status="item.old_status" />
<BIIcon name="it-arrow-right" size="xs" />
<StatusBadge :status="item.new_status" />
</div>
<div v-if="item.note" class="small mt-1 bg-light p-1 rounded">{{ item.note }}</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Modale Assegnazione -->
<div v-if="isAssignModalOpen" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<div v-if="isAssignModalOpen" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Gestione Intervento</h5>
<button type="button" class="btn-close" @click="isAssignModalOpen = false"></button>
</div>
<div class="modal-body">
<form @submit.prevent="handleAssignTeam">
<div class="mb-3">
<label class="form-label">Squadra / Ditta Incaricata</label>
<input type="text" class="form-control" v-model="teamName" placeholder="Es. Squadra Verde SPA" required />
</div>
<div class="mb-3">
<label class="form-label">Data Prevista Risoluzione (opzionale)</label>
<input type="date" class="form-control" v-model="estimatedDate" />
</div>
<div class="mb-3">
<label class="form-label">Note Interne (private)</label>
<textarea class="form-control" v-model="assignNote" rows="3" placeholder="Note ad uso interno, non visibili al cittadino..."></textarea>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<button type="button" class="btn btn-outline-secondary" @click="isAssignModalOpen = false">Annulla</button>
<button type="submit" class="btn btn-primary" :disabled="isAssigning || !teamName.trim()">
<span v-if="isAssigning" class="spinner-border spinner-border-sm me-2"></span>
Salva Assegnazione
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</template>
<style scoped>
.object-fit-cover {
object-fit: cover;
}
</style>
+10 -38
View File
@@ -41,42 +41,14 @@ const isFlagging = ref(false)
const selectedMediaIndex = ref(0) const selectedMediaIndex = ref(0)
const isGalleryOpen = ref(false) 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 // Invia messaggio
const handleSendMessage = async () => { const handleSendMessage = async (msgText: string, files: File[]) => {
if (!newMessage.value.trim()) return if (!msgText.trim() && files.length === 0) return
isSubmittingMessage.value = true isSubmittingMessage.value = true
try { try {
await addMessage(reportId, { body: newMessage.value }) // Gestione mock di file, nella realtà si usa FormData o simle
newMessage.value = '' await addMessage(reportId, { body: msgText })
await refreshMessages() await refreshMessages()
} catch (error) { } catch (error) {
console.error('Error sending message:', error) console.error('Error sending message:', error)
@@ -145,9 +117,7 @@ const formatDate = (dateString: string): string => {
<div class="col-12 col-md-8"> <div class="col-12 col-md-8">
<h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1> <h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1>
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
<span class="badge" :class="`bg-${getStatusColor(report.status)}`"> <StatusBadge :status="report.status" />
{{ getStatusLabel(report.status) }}
</span>
<small class="text-muted"> <small class="text-muted">
<BIIcon name="it-clock" size="xs" class="me-1" /> <BIIcon name="it-clock" size="xs" class="me-1" />
Creata il {{ formatDate(report.created_at) }} Creata il {{ formatDate(report.created_at) }}
@@ -230,8 +200,10 @@ const formatDate = (dateString: string): string => {
</div> </div>
<div class="flex-grow-1"> <div class="flex-grow-1">
<small class="text-muted">{{ formatDate(item.created_at) }}</small> <small class="text-muted">{{ formatDate(item.created_at) }}</small>
<div class="small font-weight-semibold"> <div class="small font-weight-semibold d-flex align-items-center gap-2 mt-1">
{{ getStatusLabel(item.old_status) }} → {{ getStatusLabel(item.new_status) }} <StatusBadge :status="item.old_status" />
<BIIcon name="it-arrow-right" size="xs" />
<StatusBadge :status="item.new_status" />
</div> </div>
<small v-if="item.note" class="text-muted d-block">{{ item.note }}</small> <small v-if="item.note" class="text-muted d-block">{{ item.note }}</small>
</div> </div>
@@ -249,7 +221,7 @@ const formatDate = (dateString: string): string => {
Chat ({{ messages.length }}) Chat ({{ messages.length }})
</h6> </h6>
<MessageList :messages="messages" /> <MessageList :messages="messages" />
<MessageComposer @send="(msg) => { newMessage = msg; handleSendMessage() }" /> <MessageComposer @send="handleSendMessage" />
</div> </div>
</div> </div>
</div> </div>
+3 -1
View File
@@ -15,11 +15,13 @@
"@nuxtjs/robots": "6.0.9", "@nuxtjs/robots": "6.0.9",
"@nuxtjs/sitemap": "8.0.15", "@nuxtjs/sitemap": "8.0.15",
"bootstrap-italia": "^2.18.1", "bootstrap-italia": "^2.18.1",
"leaflet": "^1.9.4",
"nuxt": "^4.4.7", "nuxt": "^4.4.7",
"vue": "^3.5.35", "vue": "^3.5.35",
"vue-router": "^5.1.0" "vue-router": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/fonts": "^0.14.0" "@nuxt/fonts": "^0.14.0",
"@types/leaflet": "^1.9.21"
} }
} }
+771 -1625
View File
File diff suppressed because it is too large Load Diff