Files

548 lines
16 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
definePageMeta({
middleware: 'auth',
auth: { role: 'ADMIN' }
})
useHead({ title: 'Aree - Admin' })
const { getAreas, createArea, updateArea, deleteArea } = useAdmin()
const { data: areasRes, pending, refresh } = useAsyncData('admin-areas', () => getAreas())
const isCreating = ref(false)
const editingAreaId = ref<number | null>(null)
const formData = reactive({
name: '',
description: '',
geojson: ''
})
const jsonError = ref('')
const actionError = ref('')
const modalError = ref('')
const deletingAreaId = ref<number | null>(null)
const areaPendingDelete = ref<any | null>(null)
let map: any = null
let mainMap: any = null
let polygon: any = null
const mapPoints = ref<[number, number][]>([])
const modalTitle = computed(() => editingAreaId.value ? 'Modifica Area' : 'Nuova Area')
const submitLabel = computed(() => editingAreaId.value ? 'Salva Modifiche' : 'Crea Area')
const isEditing = computed(() => editingAreaId.value !== null)
const areas = computed(() => areasRes.value?.areas || [])
const areaDeleteMessage = computed(() =>
areaPendingDelete.value ? `Vuoi eliminare area "${areaPendingDelete.value.name}"? Operazione irreversibile.` : ''
)
const initMainMap = async () => {
const mapElement = document.getElementById('mainMap')
if (!mapElement) return
const L = (await import('leaflet')).default
if (mainMap) {
mainMap.remove()
mainMap = null
}
const bariBounds = L.latLngBounds(
[BARI_BOUNDS.south, BARI_BOUNDS.west],
[BARI_BOUNDS.north, BARI_BOUNDS.east]
)
mainMap = L.map('mainMap', {
maxBounds: bariBounds,
maxBoundsViscosity: 1,
minZoom: 11
}).setView(BARI_CENTER, 12)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(mainMap)
const featureGroup = L.featureGroup().addTo(mainMap)
let hasLayers = false
areas.value.forEach((area: any) => {
if (area.boundary_geojson) {
try {
const layer = L.geoJSON(JSON.parse(area.boundary_geojson), {
style: {
color: '#0d6efd',
weight: 2,
fillOpacity: 0.2
}
}).bindPopup(`<strong>${area.name}</strong><br/>${area.description || ''}`)
layer.addTo(featureGroup)
hasLayers = true
} catch (e) {
console.error('Invalid geojson for area', area.id, e)
}
}
})
if (hasLayers) {
mainMap.fitBounds(featureGroup.getBounds())
} else {
mainMap.fitBounds(bariBounds)
}
}
watch([areas, pending], () => {
if (!pending.value) {
if (areas.value.length > 0) {
nextTick(() => {
initMainMap()
})
} else if (mainMap) {
mainMap.remove()
mainMap = null
}
}
}, { immediate: true })
const renderExistingAreasInModal = (L: any, currentMap: any) => {
areas.value.forEach((area: any) => {
if (area.id === editingAreaId.value) return
if (area.boundary_geojson) {
try {
L.geoJSON(JSON.parse(area.boundary_geojson), {
style: {
color: '#6c757d',
weight: 2,
dashArray: '5, 5',
fillOpacity: 0.1
},
interactive: false
}).addTo(currentMap)
} catch (e) {
}
}
})
}
const openCreateModal = () => {
actionError.value = ''
modalError.value = ''
editingAreaId.value = null
formData.name = ''
formData.description = ''
formData.geojson = ''
jsonError.value = ''
mapPoints.value = []
isCreating.value = true
nextTick(async () => {
const L = (await import('leaflet')).default
if (map) {
map.remove()
map = null
}
const bariBounds = L.latLngBounds(
[BARI_BOUNDS.south, BARI_BOUNDS.west],
[BARI_BOUNDS.north, BARI_BOUNDS.east]
)
map = L.map('areaMap', {
maxBounds: bariBounds,
maxBoundsViscosity: 1,
minZoom: 12
}).setView(BARI_CENTER, 12)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(map)
polygon = L.polygon([], { color: 'red' }).addTo(map)
map.fitBounds(bariBounds)
renderExistingAreasInModal(L, map)
map.on('click', (e: any) => {
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
return
}
jsonError.value = ''
modalError.value = ''
mapPoints.value.push([e.latlng.lat, e.latlng.lng])
polygon.setLatLngs(mapPoints.value)
updateGeojson()
})
})
}
const loadPolygonFromGeoJson = (boundaryGeojson: any) => {
const coordinates = boundaryGeojson?.coordinates?.[0]
if (!Array.isArray(coordinates)) {
mapPoints.value = []
return
}
const normalized = coordinates
.slice(0, -1)
.map(([longitude, latitude]: [number, number]) => [latitude, longitude] as [number, number])
mapPoints.value = normalized
if (polygon) {
polygon.setLatLngs(mapPoints.value)
}
}
const openEditModal = (area: any) => {
actionError.value = ''
modalError.value = ''
editingAreaId.value = area.id
formData.name = area.name || ''
formData.description = area.description || ''
formData.geojson = area.boundary_geojson || ''
jsonError.value = ''
mapPoints.value = []
isCreating.value = true
nextTick(async () => {
const L = (await import('leaflet')).default
if (map) {
map.remove()
map = null
}
const bariBounds = L.latLngBounds(
[BARI_BOUNDS.south, BARI_BOUNDS.west],
[BARI_BOUNDS.north, BARI_BOUNDS.east]
)
map = L.map('areaMap', {
maxBounds: bariBounds,
maxBoundsViscosity: 1,
minZoom: 12
}).setView(BARI_CENTER, 12)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(map)
console.log(JSON.parse(area.boundary_geojson))
L.geoJSON(JSON.parse(area.boundary_geojson), {
style: {
color: '#0d6efd',
weight: 2,
fillOpacity: 0.2
}
}).addTo(map)
polygon = L.polygon([], { color: 'red' }).addTo(map)
map.fitBounds(bariBounds)
renderExistingAreasInModal(L, map)
loadPolygonFromGeoJson(area.boundary_geojson)
map.on('click', (e: any) => {
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
return
}
jsonError.value = ''
modalError.value = ''
mapPoints.value.push([e.latlng.lat, e.latlng.lng])
polygon.setLatLngs(mapPoints.value)
updateGeojson()
})
})
}
const clearMap = () => {
mapPoints.value = []
if (polygon) {
polygon.setLatLngs([])
}
formData.geojson = ''
modalError.value = ''
}
const updateGeojson = () => {
if (mapPoints.value.length < 3) {
formData.geojson = ''
return
}
const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number])
if (coords[0]) {
coords.push([...coords[0]])
}
formData.geojson = JSON.stringify({
type: "Polygon",
coordinates: [coords]
}, null, 2)
}
const closeCreateModal = () => {
isCreating.value = false
editingAreaId.value = null
if (map) {
map.remove()
map = null
}
}
onBeforeUnmount(() => {
if (map) map.remove()
if (mainMap) mainMap.remove()
})
const handleCreate = async () => {
jsonError.value = ''
modalError.value = ''
let parsedGeojson = null
try {
parsedGeojson = JSON.parse(formData.geojson)
if (!parsedGeojson.type || !parsedGeojson.coordinates) {
throw new Error('GeoJSON non valido. Richiesto "type" e "coordinates".')
}
const coordinates = parsedGeojson.coordinates?.[0] || []
const outsideBari = coordinates.some(([longitude, latitude]: [number, number]) => !isWithinBariBounds(latitude, longitude))
if (outsideBari) {
throw new Error('Area non valida. Tutti i punti devono essere nel Comune di Bari.')
}
} catch (e: any) {
jsonError.value = e.message || 'Formato JSON non valido.'
return
}
try {
const payload = {
name: formData.name,
description: formData.description || undefined,
boundary_geojson: parsedGeojson
}
const res = editingAreaId.value
? await updateArea(editingAreaId.value, payload)
: await createArea(payload)
if (res.ok) {
await refresh()
closeCreateModal()
actionError.value = ''
}
} catch (err: any) {
console.error('Failed to save area', err)
const backendError = err?.data?.error || err?.response?.data?.error || err?.response?._data?.error;
if (backendError === 'area_outside_allowed_boundary') {
modalError.value = "Attenzione: l'area disegnata esce dai confini consentiti per il Comune di Bari.";
} else {
modalError.value = err?.data?.message || err?.message || "Errore durante il salvataggio dell'area.";
}
}
}
const askDeleteArea = (area: any) => {
areaPendingDelete.value = area
}
const closeDeleteAreaModal = () => {
if (deletingAreaId.value) return
areaPendingDelete.value = null
}
const handleDeleteArea = async () => {
if (!areaPendingDelete.value) return
const area = areaPendingDelete.value
deletingAreaId.value = area.id
actionError.value = ''
try {
const res = await deleteArea(area.id)
if (res.ok) {
if (editingAreaId.value === area.id) {
closeCreateModal()
}
await refresh()
areaPendingDelete.value = null
}
} catch (err: any) {
console.error('Failed to delete area', err)
actionError.value = err?.data?.message || 'Errore durante eliminazione area.'
} finally {
deletingAreaId.value = null
}
}
</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">Gestione Aree</h1>
<div>
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Torna al pannello admin</NuxtLink>
<button class="btn btn-primary btn-sm" @click="openCreateModal">
<BIIcon class="me-1" name="it-plus" size="xs"/>
Nuova Area
</button>
</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="areas.length === 0" class="alert alert-info">
Nessuna area configurata.
</div>
<template v-else>
<div class="card mb-4 shadow-sm border-0">
<div id="mainMap" class="rounded" style="height: 400px; width: 100%; z-index: 1;"></div>
</div>
<div v-if="actionError" class="alert alert-danger">
{{ actionError }}
</div>
<div v-else class="table-responsive">
<table class="table table-hover align-middle bg-white shadow-sm rounded">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Nome</th>
<th>Descrizione</th>
<th>GeoJSON</th>
<th class="text-end">Azioni</th>
</tr>
</thead>
<tbody>
<tr v-for="area in areas" :key="area.id">
<td>{{ area.id }}</td>
<td><strong>{{ area.name }}</strong></td>
<td>{{ area.description || '-' }}</td>
<td>
<span v-if="area.boundary_geojson" class="badge border border-primary text-primary">Configurato</span>
<span v-else class="badge border border-danger text-danger">Mancante</span>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary me-2" @click="openEditModal(area)">
Modifica
</button>
<button
class="btn btn-sm btn-outline-danger ms-2"
:disabled="deletingAreaId === area.id"
@click="askDeleteArea(area)"
>
<span v-if="deletingAreaId === area.id" class="spinner-border spinner-border-sm me-1"></span>
Elimina
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div v-if="isCreating" class="modal-backdrop fade show d-block"
style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<div v-if="isCreating" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ modalTitle }}</h5>
<button type="button" class="btn-close" @click="closeCreateModal"></button>
</div>
<div class="modal-body">
<div v-if="modalError" class="alert alert-danger mb-4 d-flex align-items-center" role="alert">
<strong>Errore:</strong>&nbsp;{{ modalError }}
</div>
<form @submit.prevent="handleCreate">
<div class="row">
<div class="col-md-5">
<div class="mb-3">
<label class="form-label">Nome Area</label>
<input v-model="formData.name" class="form-control" placeholder="Es. Centro Storico" required
type="text"/>
</div>
<div class="mb-3">
<label class="form-label">Descrizione (opzionale)</label>
<textarea class="form-control" v-model="formData.description" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">GeoJSON Generato</label>
<textarea
class="form-control font-monospace text-sm"
rows="6"
v-model="formData.geojson"
required
readonly
placeholder='{ "type": "Polygon", "coordinates": [...] }'
></textarea>
<div v-if="jsonError" class="form-text text-danger mt-1">{{ jsonError }}</div>
</div>
</div>
<div class="col-md-7">
<div class="mb-2 d-flex justify-content-between align-items-center">
<label class="form-label mb-0">Disegna Poligono sulla Mappa</label>
<button type="button" class="btn btn-outline-danger btn-sm" @click="clearMap">
Pulisci Mappa
</button>
</div>
<div class="alert alert-light border small mb-2 p-2">
Clicca sulla mappa per aggiungere vertici al poligono. Sono necessari almeno 3 punti. Sono ammessi
solo punti nel Comune di Bari.
</div>
<div id="areaMap" class="rounded border" style="height: 400px; width: 100%; z-index: 1;"></div>
</div>
</div>
</form>
</div>
<div class="modal-footer d-flex justify-content-end gap-2">
<button type="button" class="btn btn-outline-secondary" @click="closeCreateModal">Annulla</button>
<button :disabled="!formData.name || !formData.geojson" class="btn btn-primary" type="button"
@click="handleCreate">
{{ submitLabel }}
</button>
</div>
</div>
</div>
</div>
<ConfirmModal
:open="areaPendingDelete !== null"
title="Elimina area"
:message="areaDeleteMessage"
confirm-label="Elimina area"
confirm-class="btn-danger"
:pending="deletingAreaId !== null"
@cancel="closeDeleteAreaModal"
@confirm="handleDeleteArea"
/>
</div>
</template>
<style scoped>
#areaMap, #mainMap {
background: #e9ecef;
}
.modal-dialog {
margin-block: 0 !important;
}
</style>