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">
<!-- Utente Autenticato: Bottone Utente + Bottone Esci -->
<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">
<span class="rounded-icon" aria-hidden="true">
<BIIcon name="it-user" color="black" />
+49 -43
View File
@@ -15,7 +15,7 @@ const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
maxFiles: 5,
acceptedTypes: 'image/*,video/*',
maxFileSize: 50, // 50MB
maxFileSize: 50,
disabled: false
})
@@ -36,16 +36,14 @@ const handleFilesSelected = (selectedFiles: FileList | null) => {
const newFiles = Array.from(selectedFiles)
// Validazione numero file
if (files.value.length + newFiles.length > props.maxFiles) {
error.value = `Massimo ${props.maxFiles} file consentiti`
return
}
// Validazione dimensione
for (const file of newFiles) {
if (file.size > props.maxFileSize! * 1024 * 1024) {
error.value = `File ${file.name} supera il limite di ${props.maxFileSize}MB`
error.value = `Il file ${file.name} supera il limite di ${props.maxFileSize}MB`
return
}
}
@@ -66,17 +64,16 @@ const formatFileSize = (bytes: number): string => {
return mb > 0 ? `${mb}MB` : 'Piccolo'
}
const getMediaType = (file: File): string => {
return file.type.startsWith('video/') ? 'Video' : 'Immagine'
const getFileUrl = (file: File) => {
return URL.createObjectURL(file)
}
</script>
<template>
<div class="media-uploader">
<!-- Area trascinamento -->
<div
class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer"
:class="{ 'border-primary': isDragging, 'bg-light': !isDragging }"
class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer position-relative"
:class="{ 'border-primary bg-primary bg-opacity-10': isDragging, 'bg-light': !isDragging }"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@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" />
<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>
<small class="text-muted d-block">
Massimo {{ maxFiles }} file, fino a {{ maxFileSize }}MB per file
<small class="text-muted d-block mt-2">
Massimo {{ maxFiles }} file ({{ maxFileSize }}MB max per file). Tipi: {{ acceptedTypes }}
</small>
</div>
<!-- Input nascosto -->
<input
ref="fileInputRef"
type="file"
:multiple="props.maxFiles > 1"
:multiple="maxFiles > 1"
:accept="acceptedTypes"
:disabled="disabled"
style="display: none"
class="d-none"
@change="handleFilesSelected(($event.target as HTMLInputElement).files)"
/>
<!-- Errori -->
<div v-if="error" class="alert alert-danger mt-3 mb-0" role="alert">
<div v-if="error" class="alert alert-danger mt-3 py-2 small" role="alert">
{{ error }}
</div>
<!-- Lista file -->
<div v-if="files.length > 0" class="mt-4">
<h6 class="font-weight-bold mb-3">File caricati ({{ files.length }}/{{ maxFiles }})</h6>
<div class="list-group">
<div v-for="(file, idx) in files" :key="`file-${idx}`" class="list-group-item d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3">
<BIIcon
:name="file.type.startsWith('video/') ? 'it-video' : 'it-image'"
size="lg"
color="primary"
/>
<div>
<div class="font-weight-semibold small">{{ file.name }}</div>
<small class="text-muted">{{ getMediaType(file) }} • {{ formatFileSize(file.size) }}</small>
<div v-if="files.length > 0" class="mt-4 row g-3">
<div v-for="(file, idx) in files" :key="`file-${idx}`" class="col-6 col-md-4 col-lg-3">
<div class="card h-100 position-relative shadow-sm border-0">
<div class="ratio ratio-1x1 bg-light rounded-top overflow-hidden">
<img v-if="file.type.startsWith('image/')" :src="getFileUrl(file)" class="object-fit-cover" :alt="file.name" />
<video v-else-if="file.type.startsWith('video/')" :src="getFileUrl(file)" class="object-fit-cover"></video>
<div v-else class="d-flex align-items-center justify-content-center bg-secondary bg-opacity-25">
<BIIcon name="it-file" size="xl" color="secondary" />
</div>
</div>
<button
type="button"
class="btn btn-sm btn-link p-0"
@click="removeFile(idx)"
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"
style="width: 28px; height: 28px; z-index: 10;"
@click.stop="removeFile(idx)"
:disabled="disabled"
:aria-label="`Rimuovi ${file.name}`"
title="Rimuovi file"
>
<BIIcon name="it-close-circle" />
<BIIcon name="it-close" size="sm" color="white" />
</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>
@@ -140,21 +141,26 @@ const getMediaType = (file: File): string => {
<style scoped>
.upload-area {
transition: all 0.3s ease;
cursor: pointer;
&:hover:not(.border-primary) {
background-color: rgba(0, 111, 157, 0.05);
}
transition: all 0.2s ease-in-out;
}
.upload-area:hover:not(.border-primary) {
background-color: #f8f9fa !important;
border-color: #0066cc !important;
}
.cursor-pointer {
cursor: pointer;
}
.border-dashed {
border-style: dashed !important;
}
.border-2 {
border-width: 2px !important;
}
.object-fit-cover {
object-fit: cover;
width: 100%;
height: 100%;
}
</style>
+53 -24
View File
@@ -1,39 +1,81 @@
<script setup lang="ts">
interface Emits {
(e: 'send', message: string): void
(e: 'send', message: string, files: File[]): void
}
// Unica dichiarazione di defineEmits
const emit = defineEmits<Emits>()
const message = ref('')
const isSubmitting = ref(false)
const files = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement>()
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 = ''
files.value = []
}
const handleKeydown = (e: KeyboardEvent) => {
// Ctrl/Cmd + Enter per inviare
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
// Previene l'andata a capo predefinita della textarea
e.preventDefault()
handleSubmit()
}
}
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>
<template>
<div class="message-composer mt-4 border-top pt-3">
<!-- Non serve passare emit qui -->
<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
v-model="message"
class="form-control"
class="form-control rounded"
placeholder="Scrivi un messaggio... (Ctrl+Enter per inviare)"
rows="2"
:disabled="isSubmitting"
@@ -41,8 +83,8 @@ const handleKeydown = (e: KeyboardEvent) => {
/>
<button
type="submit"
class="btn btn-primary"
:disabled="isSubmitting || message.trim() === ''"
class="btn btn-primary ms-2 rounded"
: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>
<BIIcon v-else name="it-arrow-right" size="sm" color="white" class="me-1" />
@@ -67,17 +109,4 @@ textarea {
resize: vertical;
font-family: inherit;
}
.input-group {
display: flex;
gap: 0.5rem;
}
textarea {
flex: 1;
}
button {
flex-shrink: 0;
}
</style>
+6 -1
View File
@@ -43,7 +43,12 @@ const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userI
</div>
<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
class="message-bubble-content p-3 rounded-3"
:class="{
+115 -28
View File
@@ -1,5 +1,20 @@
<script setup lang="ts">
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 {
isLoading?: boolean
@@ -30,6 +45,66 @@ const errors = ref<Record<string, string>>({})
const successMessage = 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 => {
errors.value = {}
@@ -37,7 +112,7 @@ const validateForm = (): boolean => {
errors.value.category_id = 'Seleziona una categoria'
}
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()) {
errors.value.address = 'Inserisci l\'indirizzo'
@@ -63,13 +138,9 @@ const getGeolocation = async () => {
navigator.geolocation.getCurrentPosition(resolve, reject)
})
form.value.latitude = Number(position.coords.latitude.toFixed(6))
form.value.longitude = Number(position.coords.longitude.toFixed(6))
// Richiedi indirizzo da coordinate (reverse geocoding)
// Se il backend non lo fa, userà le coord così come sono
updateLocation(position.coords.latitude, position.coords.longitude, true)
} 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 {
isGettingLocation.value = false
}
@@ -99,26 +170,43 @@ const resetForm = () => {
}
media.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, () => {
if (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>
<template>
<div class="report-form">
<!-- Successo -->
<div v-if="successMessage" class="alert alert-success alert-dismissible fade show" role="alert">
Segnalazione creata con successo! Reindirizzamento...
<button type="button" class="btn-close" @click="successMessage = false"></button>
</div>
<form @submit.prevent="handleSubmit">
<!-- CATEGORIA -->
<div class="mb-4">
<label for="category" class="form-label fw-bold">
<BIIcon name="it-list" size="sm" color="primary" class="me-2" />
@@ -141,20 +229,12 @@ watch(() => form.value.category_id, () => {
</div>
</div>
<!-- GEOLOCALIZZAZIONE -->
<div class="mb-4">
<label class="form-label fw-bold">
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
Posizione *
</label>
<div v-if="form.latitude && form.longitude" class="alert alert-info mb-3">
<strong>Coordinate:</strong> {{ form.latitude }}, {{ form.longitude }}
<button type="button" class="btn btn-sm btn-link float-end" @click="form.latitude = null; form.longitude = null">
Cancella
</button>
</div>
<button
type="button"
class="btn btn-outline-primary w-100 mb-3"
@@ -166,9 +246,14 @@ watch(() => form.value.category_id, () => {
{{ isGettingLocation ? 'Localizzazione in corso...' : 'Usa posizione attuale' }}
</button>
<div class="alert alert-warning mb-3 small" role="alert">
Se la geolocalizzazione non funziona, inserisci manualmente coordinate o indirizzo approssimativo.
</div>
<ClientOnly>
<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="col-md-6 mb-3">
@@ -176,7 +261,7 @@ watch(() => form.value.category_id, () => {
v-model.number="form.latitude"
type="number"
step="0.000001"
class="form-control"
class="form-control form-control-sm"
:class="{ 'is-invalid': errors.location }"
placeholder="Latitudine (es. 45.5)"
required
@@ -187,19 +272,18 @@ watch(() => form.value.category_id, () => {
v-model.number="form.longitude"
type="number"
step="0.000001"
class="form-control"
class="form-control form-control-sm"
:class="{ 'is-invalid': errors.location }"
placeholder="Longitudine (es. 10.2)"
required
/>
</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 }}
</div>
</div>
<!-- INDIRIZZO -->
<div class="mb-4">
<label for="address" class="form-label fw-bold">
<BIIcon name="it-home" size="sm" color="primary" class="me-2" />
@@ -219,7 +303,6 @@ watch(() => form.value.category_id, () => {
</div>
</div>
<!-- DESCRIZIONE -->
<div class="mb-4">
<label for="description" class="form-label fw-bold">
<BIIcon name="it-write" size="sm" color="primary" class="me-2" />
@@ -242,7 +325,6 @@ watch(() => form.value.category_id, () => {
</div>
</div>
<!-- MEDIA -->
<div class="mb-4">
<label class="form-label fw-bold">
<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" />
</div>
<!-- BOTTONI -->
<div class="d-grid gap-2 d-md-flex justify-content-md-between">
<button
type="button"
@@ -281,6 +362,12 @@ watch(() => form.value.category_id, () => {
padding: 2rem;
}
.map-container {
height: 300px;
width: 100%;
z-index: 1; /* Fix for Bootstrap elements overlapping map */
}
textarea {
font-family: inherit;
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
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 => {
if (!dateString) return ''
return new Date(dateString).toLocaleDateString('it-IT', {
@@ -220,9 +204,7 @@ const formatDate = (dateString?: string): string => {
</span>
</td>
<td class="small">
<span class="badge" :class="`bg-${getStatusColor(report.status)}`">
{{ getStatusLabel(report.status) }}
</span>
<StatusBadge :status="report.status" />
</td>
<td class="small text-muted">{{ formatDate(report.created_at) }}</td>
<td class="text-center">
@@ -235,10 +217,13 @@ const formatDate = (dateString?: string): string => {
</table>
</div>
<!-- Paginazione Client-Side (Opzionale: implementare se hai molti dati) -->
<div v-if="totalFiltered > perPage" class="d-flex justify-content-center mt-4">
<!-- Qui potresti inserire un componente Pagination se ne hai uno -->
</div>
<Pagination
v-if="totalFiltered > perPage"
:current-page="page"
:per-page="perPage"
:total-items="totalFiltered"
@page-change="handlePageChange"
/>
</div>
</div>
</div>
@@ -50,7 +50,7 @@ const handleTakeReport = async (id: number) => {
>
<div>
<h6 class="mb-1">{{ report.address }}</h6>
<span class="badge bg-warning text-dark">{{ report.status }}</span>
<StatusBadge :status="report.status" />
</div>
<BIIcon name="it-chevron-right" size="sm" color="primary" />
</NuxtLink>