Files
frontend/app/components/ReportForm.vue
T
2026-06-17 22:58:38 +02:00

685 lines
19 KiB
Vue

<script setup lang="ts">
import type { Category, CreateReportRequest } from '~/types/api'
import type { LeafletMouseEvent, Map as LeafletMap, Marker as LeafletMarker } from 'leaflet'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
import { reverseGeocode, searchAddresses } from '~/utils/geocoding'
interface Props {
isLoading?: boolean
categories?: Category[]
}
interface Emits {
(e: 'submit', report: CreateReportRequest, media: File[]): void
}
interface GeocodingSuggestion {
label: string
latitude: number
longitude: number
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
categories: () => []
})
const emit = defineEmits<Emits>()
const form = ref({
category_id: null as number | null,
latitude: null as number | null,
longitude: null as number | null,
address: '',
description: '',
})
const media = ref<File[]>([])
const errors = ref<Record<string, string>>({})
const successMessage = ref(false)
const isGettingLocation = ref(false)
const addressSuggestions = ref<GeocodingSuggestion[]>([])
const isSearchingAddress = ref(false)
const isResolvingAddress = ref(false)
const addressSearchError = ref('')
const showAddressSuggestions = ref(false)
const mapContainer = ref<HTMLElement | null>(null)
let leaflet: typeof import('leaflet') | null = null
let map: LeafletMap | null = null
let marker: LeafletMarker | null = null
let addressSearchTimeout: ReturnType<typeof setTimeout> | null = null
let manualLocationSyncTimeout: ReturnType<typeof setTimeout> | null = null
let addressSearchRequestId = 0
let reverseGeocodeRequestId = 0
let lastSyncedLocationKey = ''
let skipNextAddressSearch = false
const loadLeaflet = async () => {
if (leaflet) return leaflet
const leafletModule = await import('leaflet')
const defaultIcon = leafletModule.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]
})
leafletModule.Marker.prototype.options.icon = defaultIcon
leaflet = leafletModule
return leaflet
}
const toLocationKey = (lat: number, lng: number) => `${lat.toFixed(6)}:${lng.toFixed(6)}`
const clearAddressSuggestions = () => {
addressSuggestions.value = []
showAddressSuggestions.value = false
}
const cancelPendingAddressSearch = () => {
addressSearchRequestId += 1
isSearchingAddress.value = false
if (addressSearchTimeout) {
clearTimeout(addressSearchTimeout)
addressSearchTimeout = null
}
}
const cancelPendingReverseGeocode = () => {
reverseGeocodeRequestId += 1
isResolvingAddress.value = false
}
const setAddressValue = (address: string) => {
cancelPendingAddressSearch()
skipNextAddressSearch = true
form.value.address = address
if (errors.value.address && address.trim()) {
errors.value.address = ''
}
}
const fetchAddressSuggestions = async (query: string) => {
const requestId = ++addressSearchRequestId
isSearchingAddress.value = true
addressSearchError.value = ''
try {
if (requestId !== addressSearchRequestId) {
return
}
const suggestions = await searchAddresses(query)
if (requestId !== addressSearchRequestId) {
return
}
addressSuggestions.value = suggestions
showAddressSuggestions.value = suggestions.length > 0
if (!suggestions.length) {
addressSearchError.value = 'Nessun indirizzo trovato nel Comune di Bari.'
}
} catch {
if (requestId !== addressSearchRequestId) {
return
}
clearAddressSuggestions()
addressSearchError.value = 'Ricerca indirizzi non disponibile al momento.'
} finally {
if (requestId === addressSearchRequestId) {
isSearchingAddress.value = false
}
}
}
const resolveAddressFromCoordinates = async (lat: number, lng: number, fallbackAddress = '') => {
const requestId = ++reverseGeocodeRequestId
isResolvingAddress.value = true
addressSearchError.value = ''
try {
if (requestId !== reverseGeocodeRequestId) {
return
}
const resolvedAddress = (await reverseGeocode(lat, lng))?.trim() || fallbackAddress.trim()
if (resolvedAddress) {
setAddressValue(resolvedAddress)
clearAddressSuggestions()
return
}
if (!form.value.address.trim()) {
addressSearchError.value = 'Indirizzo non trovato per coordinate selezionate.'
}
} catch {
if (requestId !== reverseGeocodeRequestId) {
return
}
if (fallbackAddress.trim()) {
setAddressValue(fallbackAddress.trim())
clearAddressSuggestions()
return
}
if (!form.value.address.trim()) {
addressSearchError.value = 'Impossibile recuperare indirizzo da coordinate.'
}
} finally {
if (requestId === reverseGeocodeRequestId) {
isResolvingAddress.value = false
}
}
}
const handleAddressFocus = () => {
if (addressSuggestions.value.length > 0) {
showAddressSuggestions.value = true
}
}
const handleAddressBlur = () => {
setTimeout(() => {
showAddressSuggestions.value = false
}, 150)
}
const updateLocation = async (
lat: number,
lng: number,
options: {
zoomTo?: boolean
syncAddress?: boolean
fallbackAddress?: string
} = {}
) => {
const { zoomTo = false, syncAddress = true, fallbackAddress = '' } = options
if (!isWithinBariBounds(lat, lng)) {
errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.'
return
}
const roundedLat = Number(lat.toFixed(6))
const roundedLng = Number(lng.toFixed(6))
lastSyncedLocationKey = toLocationKey(roundedLat, roundedLng)
form.value.latitude = roundedLat
form.value.longitude = roundedLng
if (map && leaflet) {
if (!marker) {
marker = leaflet.marker([roundedLat, roundedLng]).addTo(map)
} else {
marker.setLatLng([roundedLat, roundedLng])
}
if (zoomTo) {
map.setView([roundedLat, roundedLng], 16)
}
}
if (errors.value.location) {
errors.value.location = ''
}
if (syncAddress) {
await resolveAddressFromCoordinates(roundedLat, roundedLng, fallbackAddress)
} else if (fallbackAddress.trim()) {
setAddressValue(fallbackAddress.trim())
}
}
const initMap = async () => {
if (!import.meta.client || !mapContainer.value || map) return
const L = await loadLeaflet()
if (!mapContainer.value || map) return
const bariBounds = L.latLngBounds(
[BARI_BOUNDS.south, BARI_BOUNDS.west],
[BARI_BOUNDS.north, BARI_BOUNDS.east]
)
map = L.map(mapContainer.value, {
maxBounds: bariBounds,
maxBoundsViscosity: 1,
minZoom: 12
}).setView(BARI_CENTER, 12)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map)
map.fitBounds(bariBounds)
map.on('click', (e: LeafletMouseEvent) => {
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.'
return
}
void updateLocation(e.latlng.lat, e.latlng.lng)
})
if (form.value.latitude && form.value.longitude) {
void updateLocation(form.value.latitude, form.value.longitude, { zoomTo: true })
}
}
const selectAddressSuggestion = async (suggestion: GeocodingSuggestion) => {
addressSearchError.value = ''
clearAddressSuggestions()
setAddressValue(suggestion.label)
await updateLocation(suggestion.latitude, suggestion.longitude, {
zoomTo: true,
syncAddress: false
})
}
const handleAddressEnter = async () => {
if (addressSuggestions.value.length === 0) {
return
}
await selectAddressSuggestion(addressSuggestions.value[0])
}
onMounted(() => {
void initMap()
})
onUnmounted(() => {
cancelPendingAddressSearch()
cancelPendingReverseGeocode()
if (manualLocationSyncTimeout) {
clearTimeout(manualLocationSyncTimeout)
manualLocationSyncTimeout = null
}
if (map) {
map.remove()
map = null
}
})
const validateForm = (): boolean => {
errors.value = {}
if (!form.value.category_id) {
errors.value.category_id = 'Seleziona una categoria'
}
if (!form.value.latitude || !form.value.longitude) {
errors.value.location = 'Imposta la posizione sulla mappa'
} else if (!isWithinBariBounds(form.value.latitude, form.value.longitude)) {
errors.value.location = 'La posizione deve essere nel Comune di Bari'
}
if (!form.value.address?.trim()) {
errors.value.address = 'Inserisci l\'indirizzo'
}
if (!form.value.description?.trim() || form.value.description.length < 10) {
errors.value.description = 'Descrivi il problema (minimo 10 caratteri)'
}
return Object.keys(errors.value).length === 0
}
const getGeolocation = async () => {
if (!navigator.geolocation) {
errors.value.location = 'Geolocalizzazione non disponibile nel tuo browser'
return
}
isGettingLocation.value = true
errors.value.location = ''
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject)
})
await updateLocation(position.coords.latitude, position.coords.longitude, { zoomTo: true })
} catch (error: any) {
errors.value.location = 'Impossibile ottenere la posizione. Clicca sulla mappa per inserirla manualmente.'
} finally {
isGettingLocation.value = false
}
}
const handleSubmit = async () => {
if (!validateForm()) return
const reportData: CreateReportRequest = {
category_id: form.value.category_id!,
latitude: form.value.latitude!,
longitude: form.value.longitude!,
address: form.value.address,
description: form.value.description,
}
emit('submit', reportData, media.value)
}
const resetForm = () => {
form.value = {
category_id: null,
latitude: null,
longitude: null,
address: '',
description: '',
}
media.value = []
errors.value = {}
addressSearchError.value = ''
clearAddressSuggestions()
cancelPendingAddressSearch()
cancelPendingReverseGeocode()
lastSyncedLocationKey = ''
skipNextAddressSearch = false
if (marker && map) {
map.removeLayer(marker)
marker = null
map.setView(BARI_CENTER, 12)
}
}
watch(() => form.value.category_id, () => {
if (errors.value.category_id) {
errors.value.category_id = ''
}
})
watch(() => form.value.address, (newAddress) => {
if (skipNextAddressSearch) {
skipNextAddressSearch = false
return
}
cancelPendingReverseGeocode()
if (errors.value.address && newAddress.trim()) {
errors.value.address = ''
}
if (addressSearchTimeout) {
clearTimeout(addressSearchTimeout)
addressSearchTimeout = null
}
if (newAddress.trim().length < 3) {
isSearchingAddress.value = false
addressSearchError.value = ''
clearAddressSuggestions()
return
}
addressSearchTimeout = setTimeout(() => {
void fetchAddressSuggestions(newAddress.trim())
}, 350)
})
watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => {
if (newLat && newLng && map && leaflet) {
if (!marker || marker.getLatLng().lat !== newLat || marker.getLatLng().lng !== newLng) {
if (!marker) {
marker = leaflet.marker([newLat, newLng]).addTo(map)
} else {
marker.setLatLng([newLat, newLng])
}
map.setView([newLat, newLng], 16)
}
}
if (!newLat || !newLng) {
return
}
const locationKey = toLocationKey(newLat, newLng)
if (locationKey === lastSyncedLocationKey) {
return
}
if (manualLocationSyncTimeout) {
clearTimeout(manualLocationSyncTimeout)
}
manualLocationSyncTimeout = setTimeout(() => {
void updateLocation(newLat, newLng, { zoomTo: true })
}, 500)
})
</script>
<template>
<div class="report-form">
<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">
<div class="mb-4">
<label for="category" class="form-label fw-bold">
<BIIcon name="it-list" size="sm" color="primary" class="me-2" />
Categoria del problema *
</label>
<select
id="category"
v-model.number="form.category_id"
class="form-select"
:class="{ 'is-invalid': errors.category_id }"
required
>
<option :value="null">-- Seleziona una categoria --</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</option>
</select>
<div v-if="errors.category_id" class="invalid-feedback">
{{ errors.category_id }}
</div>
</div>
<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>
<p class="small text-muted mb-2">Le segnalazioni sono abilitate solo entro confini del Comune di Bari.</p>
<button
type="button"
class="btn btn-outline-primary w-100 mb-3"
@click="getGeolocation"
:disabled="isGettingLocation"
>
<span v-if="isGettingLocation" class="spinner-border spinner-border-sm me-2"></span>
<BIIcon v-else name="it-location" size="sm" class="me-2" />
{{ isGettingLocation ? 'Localizzazione in corso...' : 'Usa posizione attuale' }}
</button>
<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">
<input
v-model.number="form.latitude"
type="number"
step="0.000001"
class="form-control form-control-sm"
:class="{ 'is-invalid': errors.location }"
placeholder="Latitudine (es. 45.5)"
required
/>
</div>
<div class="col-md-6 mb-3">
<input
v-model.number="form.longitude"
type="number"
step="0.000001"
class="form-control 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 mt-0 mb-3">
{{ errors.location }}
</div>
</div>
<div class="mb-4">
<label for="address" class="form-label fw-bold">
<BIIcon name="it-home" size="sm" color="primary" class="me-2" />
Indirizzo *
</label>
<div class="position-relative">
<input
id="address"
v-model="form.address"
type="text"
class="form-control"
:class="{ 'is-invalid': errors.address }"
placeholder="Es. Via Sparano 12, Bari"
autocomplete="street-address"
required
@focus="handleAddressFocus"
@blur="handleAddressBlur"
@keydown.enter.prevent="handleAddressEnter"
/>
<div
v-if="showAddressSuggestions && addressSuggestions.length > 0"
class="list-group position-absolute w-100 shadow-sm rounded-3 overflow-hidden address-suggestions"
>
<button
v-for="suggestion in addressSuggestions"
:key="`${suggestion.latitude}-${suggestion.longitude}-${suggestion.label}`"
type="button"
class="list-group-item list-group-item-action text-start"
@mousedown.prevent="selectAddressSuggestion(suggestion)"
>
{{ suggestion.label }}
</button>
</div>
</div>
<div v-if="isSearchingAddress" class="form-text">
Ricerca indirizzi in corso...
</div>
<div v-else-if="isResolvingAddress" class="form-text">
Recupero indirizzo da coordinate...
</div>
<div v-else-if="addressSearchError" class="form-text text-muted">
{{ addressSearchError }}
</div>
<div v-if="errors.address" class="invalid-feedback">
{{ errors.address }}
</div>
</div>
<div class="mb-4">
<label for="description" class="form-label fw-bold">
<BIIcon name="it-write" size="sm" color="primary" class="me-2" />
Descrizione del problema *
</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
:class="{ 'is-invalid': errors.description }"
rows="5"
placeholder="Descrivi dettagliatamente il problema (tipo buca stradale, pericolo, ecc.)"
required
/>
<small class="form-text text-muted d-block mt-1">
{{ form.description.length }} caratteri (minimo 10)
</small>
<div v-if="errors.description" class="invalid-feedback d-block">
{{ errors.description }}
</div>
</div>
<div class="mb-4">
<label class="form-label fw-bold">
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
Allega foto/video (opzionale)
</label>
<MediaUploader v-model="media" :max-files="5" :max-file-size="50" />
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-between">
<button
type="button"
class="btn btn-outline-secondary"
@click="resetForm"
:disabled="isLoading"
>
Annulla
</button>
<button
type="submit"
class="btn btn-primary btn-lg"
:disabled="isLoading"
>
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
{{ isLoading ? 'Invio segnalazione...' : 'Invia segnalazione' }}
</button>
</div>
</form>
</div>
</template>
<style scoped>
.report-form {
background-color: #fff;
border-radius: 0.5rem;
padding: 2rem;
}
.map-container {
height: 300px;
width: 100%;
z-index: 1;
overflow: hidden;
}
.address-suggestions {
top: calc(100% + 0.25rem);
left: 0;
z-index: 10;
max-height: 16rem;
overflow-y: auto;
}
textarea {
font-family: inherit;
resize: vertical;
}
.form-label {
margin-bottom: 0.75rem;
display: flex;
align-items: center;
}
</style>