Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
&:hover:not(.border-primary) {
|
|
||||||
background-color: rgba(0, 111, 157, 0.05);
|
|
||||||
}
|
}
|
||||||
|
.upload-area:hover:not(.border-primary) {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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: '© 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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -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.'
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
← 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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user