1 Commits
40 changed files with 985 additions and 5118 deletions
+53 -103
View File
@@ -1,125 +1,75 @@
<div align="center"> # Nuxt Minimal Starter
<img src="https://git.reloia.uk/SnitchItaly/frontend/raw/branch/master/public/icon-512.webp" width="128" height="128" alt="Logo Visione">
<h1>Visione</h1>
<p><b>Un'occhio per la sicurezza del cittadino.</b></p>
</div>
**Visione** è una piattaforma progettata per gestire le segnalazioni dei cittadini, implementando un flusso operativo Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
sicuro, trasparente e istituzionalmente conforme.
## Punti di Forza e Funzionalità Principali ## Setup
- **Conformità Istituzionale e UX/UI Standardizzata:** Il frontend è sviluppato utilizzando **Bootstrap Italia** (creato Make sure to install dependencies:
da Designers Italia). Questa scelta assicura un'identità visiva coerente con i servizi pubblici italiani, garantendo
uniformità, riconoscibilità e accessibilità.
- **Trasparenza e Open Data:** Il sistema espone un endpoint pubblico Open Data con dataset anonimizzati relativi alle
segnalazioni chiuse o risolte, favorendo la trasparenza amministrativa.
- **Automazione e Smistamento Intelligente:** Il calcolo automatico della "Zona" di competenza tramite le coordinate
geografiche elimina i colli di bottiglia durante il triage iniziale, instradando immediatamente la segnalazione
all'operatore corretto.
- **Sicurezza e Onboarding Smart:** L'autenticazione è gestita tramite **OTP (One-Time Password)** e integrazione con *
*CIE (Carta d'Identità Elettronica)**, riducendo l'uso di password statiche e garantendo identità verificate.
- **Infrastruttura DevOps e CI/CD:** Sfruttiamo un'istanza self-hosted di **Gitea** per il versionamento del codice e un
flusso di deployment automatizzato basato su webhook con **Dokploy**, assicurando un rilascio continuo e stabile.
- **Ottimizzazione SEO e Reperibilità:** Configurazione avanzata per sitemap e `robots.txt` (via `@nuxtjs/robots` e
`@nuxtjs/sitemap`) per esplicitare chiaramente le pagine pubbliche e separarle dalle aree private.
## Tecnologie Utilizzate
Il progetto è costruito su uno stack moderno orientato a performance, accessibilità e manutenibilità:
- **Framework Core:** [Nuxt 4](https://nuxt.com/) (Vue 3) con rendering SSR attivato.
- **UI / Styling:** [Bootstrap Italia](https://italia.github.io/bootstrap-italia/) per componenti accessibili e conformi
agli standard AGID.
- **Mappe e Georeferenziazione:** [Leaflet](https://leafletjs.com/) interfacciato con Nominatim/OpenStreetMap per il
suggerimento e la validazione degli indirizzi.
- **PWA e Ottimizzazione:** Modulo `@vite-pwa/nuxt` per Progressive Web App (manifest auto-generato) e moduli ufficiali
Nuxt per icone, font (Google Fonts: Lora, Roboto Mono, Titillium Web) ed elaborazione immagini.
- **Infrastruttura:** Docker, Docker Compose, NodeJS 22.
- **Package Manager:** **Yarn**.
## Limiti e Punti di Attenzione
L'attuale architettura presenta alcune criticità e aree di miglioramento individuate durante i test:
1. **Rischio di Abuso e Moderazione:** L'apertura della piattaforma all'invio di messaggi e allegati potrebbe generare
un elevato carico di moderazione. In futuro, andranno valutati filtri automatici o code dedicate.
2. **Dipendenza da API Cartografiche di Terze Parti:** L'utilizzo di Nominatim/OpenStreetMap per il geocoding esonera
dal lock-in di provider commerciali (es. Google Maps), ma introduce dipendenze su disponibilità e policy d'uso di
servizi esterni.
3. **Barriere all'Ingresso:** I flussi OTP e CIE, seppur estremamente sicuri, possono rappresentare un "attrito" (
digital divide) per le fasce di utenza più anziane o meno avvezze agli strumenti digitali.
---
## Come utilizzare il progetto (Sviluppo)
### Requisiti
- Node.js (v22 o superiore consigliato)
- **Yarn** (utilizza esclusivamente `yarn`, non `npm`)
### 1. Installazione Dipendenze
```bash ```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install yarn install
# bun
bun install
``` ```
### 2. Configurazione Variabili d'Ambiente (.env) ## Development Server
Copia il file di esempio per creare la tua configurazione locale: Start the development server on `http://localhost:3000`:
```bash ```bash
cp .env.example .env # npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
``` ```
Il file `.env` richiede le seguenti chiavi: ## Production
- `NUXT_PUBLIC_URL`: URL pubblico dell'applicativo frontend (es. `http://localhost:3000`). Build the application for production:
- `NUXT_PUBLIC_API`: URL base per il backend API (es. `http://localhost:5555`).
*(Nota: il proxy integrato in `nuxt.config.ts` inoltrerà già le richieste di `/api/v1` al target di staging
configurato `https://api.visionebari.it` in ambiente di sviluppo).*
### 3. Avvio del Server di Sviluppo
```bash ```bash
yarn run dev # npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
``` ```
Il server sarà accessibile all'indirizzo [http://localhost:3000](http://localhost:3000). Locally preview production build:
---
## Distribuzione e Produzione (Docker)
Il progetto è ottimizzato per essere deployato tramite container Docker, sfruttando processi multi-stage per generare
un'immagine finale snella e performante basata su `node:22-bookworm`.
### Build locale e avvio tramite Docker Compose
Il file `docker-compose.yml` espone il servizio `visione-frontend`.
1. Assicurati che il file `.env` sia valorizzato correttamente.
2. Esegui il build e avvia in background:
```bash ```bash
docker compose up -d --build # npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
``` ```
### Dettagli sul Dockerfile Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
- **Fase di Build (`AS build`):** Copia `package.json` e `yarn.lock`, esegue `yarn install`, copia i sorgenti ed esegue
`yarn run build`. Iniettando gli argomenti di environment per le API.
- **Fase di Run (`AS run`):** Esegue `yarn install --production` per alleggerire le dipendenze finali. Trasferisce
l'artefatto compilato `.output` e avvia il webserver sulla porta `80` tramite `node /app/server/index.mjs`.
## Testing e Azioni Migliorative Future
I test attuali coprono con successo i flussi primari (registrazione, autenticazione, gestione segnalazioni,
moderazione).
Prossimi step suggeriti:
- Consolidamento con test end-to-end (E2E) automatici per le rotte critiche (in particolare CIE/OTP).
- Verifiche di regressione per gli endpoint Open Data per garantirne l'integrità.
- Monitoraggio della qualità del dato geografico (segnalazioni in aree limite).
+2 -3
View File
@@ -1,15 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
useSeoMeta({ useSeoMeta({
title: "Visione", title: "Visione - UniBA",
ogTitle: "Visione", ogTitle: "Visione - UniBA",
description: "Un'occhio per la sicurezza del cittadino.", description: "Un'occhio per la sicurezza del cittadino.",
ogDescription: "Un'occhio per la sicurezza del cittadino.", ogDescription: "Un'occhio per la sicurezza del cittadino.",
ogImage: "/favicon.png" ogImage: "/favicon.png"
}); });
</script> </script>
<template> <template>
<NuxtPwaManifest/>
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
+24 -252
View File
@@ -1,58 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import MemberDot from "~/components/MemberDot.vue";
import { useMembers } from "~/composables/useMembers"; import { useMembers } from "~/composables/useMembers";
const { sortedMembers } = useMembers(); const { sortedMembers } = useMembers();
const footerLinks = [
{ label: "Home", path: "/" },
{ label: "Esplora i dati", path: "/open-data" },
{ label: "Come funziona", path: "/info" },
];
const supportItems = [
{ label: "E-mail", value: "[email protected]", href: "mailto:[email protected]" },
{ label: "Telefono", value: "+39 080 468 2010", href: "tel:+390804682010" },
{ label: "Supporto", value: "Lun - Ven, 9:00 - 18:00" },
];
</script> </script>
<template> <template>
<footer class="it-footer"> <footer class="it-footer">
<div class="it-footer-main"> <div class="it-footer-main">
<div class="container footer-main-content"> <div class="container">
<section class="footer-brand-section"> <section>
<div class="row clearfix">
<div class="col-sm-12">
<div class="it-brand-wrapper"> <div class="it-brand-wrapper">
<NuxtLink to="/" data-focus-mouse="false" class="footer-brand-link"> <a href="#" data-focus-mouse="false" class="d-flex align-items-center">
<img alt="Logo Visione" class="footer-brand-logo" src="/logo-light.svg"/> <img alt="Logo Visione" src="/logo-light.svg" style="width: 42px; height: 42px; margin-right: 12px;"/>
<div class="it-brand-text"> <div class="it-brand-text">
<h2>Visione</h2> <h2>Visione</h2>
<h3>Un occhio per la sicurezza di Bari</h3> <h3 class="d-none d-md-block">Segnalazioni anonime e sicure</h3>
</div>
</a>
</div>
</div> </div>
</NuxtLink>
</div> </div>
</section> </section>
<section class="py-4 border-white border-top">
<section class="footer-content-section"> <div class="row">
<div class="footer-support"> <div class="col-12 pb-2">
<h4>Assistenza per i cittadini</h4> <div class="pb-2">
<ul class="footer-support-list"> <h4>Progetto di</h4>
<li v-for="item in supportItems" :key="item.label"> <div class="members-list d-flex justify-content-center align-items-center gap-5 flex-wrap w-100">
<span class="footer-support-label">{{ item.label }}:</span> <MemberDot
<a v-if="item.href" :href="item.href">{{ item.value }}</a>
<span v-else>{{ item.value }}</span>
</li>
</ul>
</div>
<div class="footer-members">
<div class="footer-members-grid">
<div
v-for="member in sortedMembers" v-for="member in sortedMembers"
:key="member.name" :key="member.name"
class="footer-member-card" :name="member.name"
> :image="member.image"
<img :src="member.image" :alt="member.name" class="footer-member-image"> />
<div class="footer-member-name">{{ member.name }}</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -61,225 +46,12 @@ const supportItems = [
</div> </div>
<div class="it-footer-small-prints clearfix"> <div class="it-footer-small-prints clearfix">
<div class="container"> <div class="container">
<div class="footer-lower-bar"> <ul class="it-footer-small-prints-list list-inline mb-0 d-flex flex-column flex-md-row">
<ul class="footer-primary-menu list-inline mb-0">
<li v-for="link in footerLinks" :key="link.path" class="list-inline-item">
<NuxtLink :to="link.path">{{ link.label }}</NuxtLink>
</li>
</ul>
<ul class="it-footer-small-prints-list list-inline mb-0">
<li class="list-inline-item"><a href="/note-legali">Note legali</a></li> <li class="list-inline-item"><a href="/note-legali">Note legali</a></li>
<li class="list-inline-item"><a href="/privacy">Privacy policy</a></li> <li class="list-inline-item"><a href="/privacy">Privacy policy</a></li>
<li class="list-inline-item"><a href="/sitemap.xml">Mappa del sito</a></li> <li class="list-inline-item"><a href="/sitemap.xml">Mappa del sito</a></li>
</ul> </ul>
</div> </div>
</div> </div>
</div>
</footer> </footer>
</template> </template>
<style scoped>
.footer-main-content {
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
.footer-brand-section {
padding-bottom: 1.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.28);
}
.footer-brand-link {
display: inline-flex;
align-items: center;
gap: 1rem;
text-decoration: none;
}
.footer-brand-logo {
width: 52px;
height: 52px;
flex-shrink: 0;
}
.footer-brand-section .it-brand-text h2 {
margin-bottom: 0.25rem;
font-size: 1.75rem;
}
.footer-brand-section .it-brand-text h3 {
margin-bottom: 0;
font-size: 1rem;
font-weight: 400;
}
.footer-content-section {
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
padding-top: 2rem;
}
.footer-support {
max-width: 30rem;
}
.footer-support h4 {
margin-bottom: 1.25rem;
font-size: 1.3rem;
text-transform: uppercase;
}
.footer-support-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.75rem;
font-size: 1.05rem;
}
.footer-support-label {
font-weight: 700;
margin-right: 0.35rem;
}
.footer-members {
flex: 1;
display: flex;
justify-content: flex-end;
}
.footer-members-grid {
display: grid;
grid-template-columns: repeat(3, minmax(110px, 140px));
gap: 1rem;
align-items: start;
}
.footer-member-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
}
.footer-member-image {
width: 100%;
max-width: 126px;
aspect-ratio: 1;
border-radius: 50%;
object-fit: cover;
background: rgba(255, 255, 255, 0.16);
border: 2px solid rgba(255, 255, 255, 0.2);
}
.footer-member-name {
font-size: 0.95rem;
line-height: 1.25;
font-weight: 600;
}
.footer-lower-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
padding-top: 1rem;
padding-bottom: 1rem;
}
.footer-primary-menu,
.it-footer-small-prints-list {
display: flex;
flex-wrap: wrap;
gap: 1rem 1.5rem;
align-items: center;
}
.footer-primary-menu .list-inline-item,
.it-footer-small-prints-list .list-inline-item {
margin: 0;
}
.footer-primary-menu a,
.it-footer-small-prints-list a {
text-decoration: none;
}
.footer-primary-menu a:hover,
.footer-primary-menu a:focus,
.it-footer-small-prints-list a:hover,
.it-footer-small-prints-list a:focus {
text-decoration: underline;
}
@media (max-width: 991.98px) {
.footer-content-section {
align-items: flex-start;
flex-direction: column;
}
.footer-support {
max-width: none;
}
.footer-members {
width: 100%;
justify-content: flex-start;
}
.footer-lower-bar {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 767.98px) {
.footer-main-content {
padding-top: 2rem;
padding-bottom: 2rem;
}
.footer-brand-link {
align-items: flex-start;
}
.footer-brand-section .it-brand-text h2 {
font-size: 1.45rem;
}
.footer-brand-section .it-brand-text h3 {
font-size: 0.95rem;
}
.footer-support h4 {
font-size: 1.1rem;
}
.footer-support-list {
font-size: 1rem;
}
.footer-members-grid {
width: 100%;
grid-template-columns: repeat(2, minmax(110px, 1fr));
}
.footer-primary-menu,
.it-footer-small-prints-list {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
}
@media (max-width: 479.98px) {
.footer-members-grid {
grid-template-columns: 1fr;
max-width: 150px;
}
}
</style>
+3 -26
View File
@@ -26,7 +26,7 @@ onUnmounted(() => {
} }
}) })
const { isAuthenticated, user, logout, hasUnreadNotifications } = useAuth() const { isAuthenticated, user, logout } = useAuth()
const panelLabel = computed(() => isAdmin(user.value?.role) ? 'Pannello' : 'Dashboard') const panelLabel = computed(() => isAdmin(user.value?.role) ? 'Pannello' : 'Dashboard')
const navLinks = [ const navLinks = [
@@ -70,11 +70,10 @@ const navLinks = [
<NuxtLink class="btn btn-primary btn-icon btn-full" to="/profile" <NuxtLink class="btn btn-primary btn-icon btn-full" to="/profile"
data-element="personal-area-dashboard" data-focus-mouse="false"> data-element="personal-area-dashboard" data-focus-mouse="false">
<span class="rounded-icon profile-icon-wrap" aria-hidden="true"> <span class="rounded-icon" aria-hidden="true">
<BIIcon name="it-user" color="black"/> <BIIcon name="it-user" color="black"/>
<span v-if="hasUnreadNotifications" class="notification-dot"></span>
</span> </span>
<span class="d-none d-lg-block username">{{ user?.first_name }}</span> <span class="d-none d-lg-block">{{ user?.first_name }}</span>
</NuxtLink> </NuxtLink>
<button class="btn btn-primary btn-icon btn-full border-0" @click="logout" <button class="btn btn-primary btn-icon btn-full border-0" @click="logout"
@@ -295,26 +294,4 @@ const navLinks = [
height: 48px; height: 48px;
margin-inline: 16px; margin-inline: 16px;
} }
.profile-icon-wrap {
position: relative;
}
.notification-dot {
position: absolute;
top: -2px;
right: -2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #d9364f;
border: 2px solid #ffffff;
}
.username {
max-width: 8ch;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style> </style>
+18 -19
View File
@@ -1,4 +1,4 @@
<script lang="ts" setup> <script setup lang="ts">
interface Props { interface Props {
modelValue?: File[] modelValue?: File[]
maxFiles?: number maxFiles?: number
@@ -72,14 +72,14 @@ const getFileUrl = (file: File) => {
<template> <template>
<div class="media-uploader"> <div class="media-uploader">
<div <div
:class="{ 'border-primary bg-primary bg-opacity-10': isDragging, 'bg-light': !isDragging }"
class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer position-relative" class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer position-relative"
@click="!disabled && openFileDialog()" :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)"
@click="!disabled && openFileDialog()"
> >
<BIIcon class="mb-3" color="primary" name="it-upload" size="xl"/> <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 text-decoration-underline">clicca per selezionare</span> Trascina i file qui o <span class="text-primary text-decoration-underline">clicca per selezionare</span>
</div> </div>
@@ -90,11 +90,11 @@ const getFileUrl = (file: File) => {
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file"
:multiple="maxFiles > 1"
:accept="acceptedTypes" :accept="acceptedTypes"
:disabled="disabled" :disabled="disabled"
:multiple="maxFiles > 1"
class="d-none" class="d-none"
type="file"
@change="handleFilesSelected(($event.target as HTMLInputElement).files)" @change="handleFilesSelected(($event.target as HTMLInputElement).files)"
/> />
@@ -106,27 +106,26 @@ const getFileUrl = (file: File) => {
<div v-for="(file, idx) in files" :key="`file-${idx}`" class="col-6 col-md-4 col-lg-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="card h-100 position-relative shadow-sm border-0">
<div class="ratio ratio-1x1 bg-light rounded-top overflow-hidden"> <div class="ratio ratio-1x1 bg-light rounded-top overflow-hidden">
<img v-if="file.type.startsWith('image/')" :alt="file.name" :src="getFileUrl(file)" <img v-if="file.type.startsWith('image/')" :src="getFileUrl(file)" class="object-fit-cover" :alt="file.name" />
class="object-fit-cover"/>
<video v-else-if="file.type.startsWith('video/')" :src="getFileUrl(file)" class="object-fit-cover"></video> <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"> <div v-else class="d-flex align-items-center justify-content-center bg-secondary bg-opacity-25">
<BIIcon color="secondary" name="it-file" size="xl"/> <BIIcon name="it-file" size="xl" color="secondary" />
</div> </div>
</div> </div>
<button <button
:disabled="disabled" type="button"
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" 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;" style="width: 28px; height: 28px; z-index: 10;"
title="Rimuovi file"
type="button"
@click.stop="removeFile(idx)" @click.stop="removeFile(idx)"
:disabled="disabled"
title="Rimuovi file"
> >
<BIIcon color="white" name="it-close" size="sm"/> <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="card-body p-2 d-flex flex-column justify-content-between">
<div :title="file.name" class="small text-truncate fw-semibold">{{ file.name }}</div> <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"> <div class="small text-muted d-flex justify-content-between align-items-center mt-1">
<span>{{ formatFileSize(file.size) }}</span> <span>{{ formatFileSize(file.size) }}</span>
<div class="progress flex-grow-1 ms-2" style="height: 4px;" title="Pronto per l'upload"> <div class="progress flex-grow-1 ms-2" style="height: 4px;" title="Pronto per l'upload">
@@ -144,27 +143,27 @@ const getFileUrl = (file: File) => {
.upload-area { .upload-area {
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }
.upload-area:hover:not(.border-primary) { .upload-area:hover:not(.border-primary) {
background-color: #f8f9fa !important; background-color: #f8f9fa !important;
border-color: #0066cc !important; border-color: #0066cc !important;
} }
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
.border-dashed { .border-dashed {
border-style: dashed !important; border-style: dashed !important;
} }
.border-2 { .border-2 {
border-width: 2px !important; border-width: 2px !important;
} }
.object-fit-cover { .object-fit-cover {
object-fit: cover; object-fit: cover;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
</style> </style>
+76 -8
View File
@@ -1,20 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
interface Emits { interface Emits {
(e: 'send', message: string): void (e: 'send', message: string, files: File[]): void
} }
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) => {
@@ -23,13 +24,55 @@ const handleKeydown = (e: KeyboardEvent) => {
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-3"> <div class="message-composer mt-3">
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div v-if="files.length > 0" class="message-files mb-3">
<div v-for="(file, idx) in files" :key="idx" class="message-file-chip">
<BIIcon name="it-clip" size="xs" />
<span class="text-truncate file-name">{{ file.name }}</span>
<button type="button" class="btn-close btn-close-sm" @click="removeFile(idx)" aria-label="Rimuovi allegato"></button>
</div>
</div>
<div class="d-flex align-items-stretch gap-2"> <div class="d-flex align-items-stretch gap-2">
<input
ref="fileInputRef"
type="file"
multiple
class="d-none"
@change="handleFileSelect"
accept="image/*,video/*,.pdf,.doc,.docx"
/>
<button
type="button"
class="btn btn-outline-primary btn-square flex-shrink-0"
title="Allega file"
@click="fileInputRef?.click()"
:disabled="isSubmitting || files.length >= 5"
>
<BIIcon name="it-clip" size="sm" />
</button>
<textarea <textarea
v-model="message" v-model="message"
class="form-control chat-textarea m-0" class="form-control chat-textarea m-0"
@@ -41,16 +84,16 @@ const handleKeydown = (e: KeyboardEvent) => {
<button <button
type="submit" type="submit"
class="btn btn-primary btn-square flex-shrink-0" class="btn btn-primary btn-square flex-shrink-0"
:disabled="isSubmitting || message.trim() === ''" :disabled="isSubmitting || (message.trim() === '' && files.length === 0)"
title="Invia" title="Invia"
> >
<span v-if="isSubmitting" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span v-if="isSubmitting" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<BIIcon v-else color="white" name="it-arrow-right" size="sm"/> <BIIcon v-else name="it-arrow-right" size="sm" color="white" />
</button> </button>
</div> </div>
<small class="text-muted d-block mt-2"> <small class="text-muted d-block mt-2">
`Ctrl` + `Invio` per inviare. `Ctrl` + `Invio` per inviare. Fino a 5 allegati.
</small> </small>
</form> </form>
</div> </div>
@@ -62,6 +105,31 @@ const handleKeydown = (e: KeyboardEvent) => {
padding-top: 1rem; padding-top: 1rem;
} }
.message-files {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.message-file-chip {
align-items: center;
background: #fff;
border: 1px solid #d9e3ee;
border-radius: 4px;
display: inline-flex;
gap: 0.5rem;
max-width: 100%;
padding: 0.4rem 0.75rem;
}
.file-name {
max-width: 14rem;
}
.btn-close-sm {
font-size: 0.55rem;
}
.chat-textarea { .chat-textarea {
resize: none !important; resize: none !important;
height: 48px; height: 48px;
+2 -2
View File
@@ -80,11 +80,11 @@ const reportHref = (report: Report) => `${props.detailBasePath}/${report.id}`
<NuxtLink <NuxtLink
v-else v-else
:to="reportHref(report)" :to="reportHref(report)"
class="btn btn-outline-primary btn-xs" class="btn btn-outline-primary btn-xs btn-icon d-inline-flex align-items-center justify-content-center"
:aria-label="actionLabel" :aria-label="actionLabel"
:title="actionLabel" :title="actionLabel"
> >
{{ actionLabel }} <BIIcon name="it-arrow-right" size="xs" />
</NuxtLink> </NuxtLink>
</td> </td>
</tr> </tr>
+1 -1
View File
@@ -15,7 +15,7 @@ const badgeConfig = computed(() => {
IN_PROGRESS: { color: 'primary', text: 'In Lavorazione', icon: 'it-settings' }, IN_PROGRESS: { color: 'primary', text: 'In Lavorazione', icon: 'it-settings' },
RESOLVED: { color: 'success', text: 'Risolta', icon: 'it-check-circle' }, RESOLVED: { color: 'success', text: 'Risolta', icon: 'it-check-circle' },
REJECTED: { color: 'danger', text: 'Rifiutata', icon: 'it-close-circle' }, REJECTED: { color: 'danger', text: 'Rifiutata', icon: 'it-close-circle' },
CLOSED: { color: 'secondary', text: 'Conclusa', icon: 'it-locked' } CLOSED: { color: 'secondary', text: 'Chiusa', icon: 'it-locked' }
} }
return config[props.status] || { color: 'secondary', text: props.status, icon: 'it-info-circle' } return config[props.status] || { color: 'secondary', text: props.status, icon: 'it-info-circle' }
+52 -34
View File
@@ -2,48 +2,66 @@
const { getOverviewStats } = useAdmin() const { getOverviewStats } = useAdmin()
const { data: statsRes, pending } = useAsyncData('admin-stats', () => getOverviewStats()) const { data: statsRes, pending } = useAsyncData('admin-stats', () => getOverviewStats())
const adminActions = [
{ to: '/admin/reports', icon: 'it-files', label: 'Report', tone: 'primary' },
{ to: '/admin/catalog', icon: 'it-list', label: 'Categorie e Team', tone: 'primary' },
{ to: '/admin/areas', icon: 'it-map-marker', label: 'Aree', tone: 'primary' },
{ to: '/admin/users', icon: 'it-user', label: 'Gestione Utenti', tone: 'primary' },
{ to: '/admin/moderation', icon: 'it-warning', label: 'Moderazione Abusi', tone: 'danger' }
]
</script> </script>
<template> <template>
<div> <div>
<div class="card card-bg"> <div class="callout callout-highlight callout-danger mb-4">
<div class="card-body"> <div class="callout-title">Pannello Admin</div>
<div class="d-flex justify-content-between align-items-center mb-3"> <p>Accesso di livello 1. Da qui puoi gestire le configurazioni di sistema, le categorie e moderare le segnalazioni.</p>
<h2 class="h5 card-title mb-0">Gestione sistema</h2>
</div> </div>
<div class="row g-2"> <div class="row mb-5" v-if="!pending && statsRes?.ok">
<div <div class="col-6 col-md-3 mb-3">
v-for="action in adminActions" <div class="card-wrapper card-space">
:key="action.to" <div class="card card-bg text-center">
class="col-12 col-md-6 col-xl-4" <div class="card-body">
> <h2 class="text-primary">{{ statsRes.total_users || 0 }}</h2>
<NuxtLink <p class="card-text">Utenti Registrati</p>
:to="action.to" </div>
class="btn btn-sm w-100 d-flex align-items-center justify-content-start gap-2 admin-action" </div>
:class="action.tone === 'danger' ? 'btn-outline-danger' : 'btn-outline-primary'" </div>
> </div>
<BIIcon :name="action.icon" size="sm" :color="action.tone === 'danger' ? 'danger' : 'primary'" /> <div class="col-6 col-md-3 mb-3">
<span>{{ action.label }}</span> <div class="card-wrapper card-space">
<div class="card card-bg text-center">
<div class="card-body">
<h2 class="text-warning">
{{ statsRes.open_reports ?? statsRes.reports_by_status?.find(s => s.status === 'OPEN')?.total ?? 0 }}
</h2>
<p class="card-text">Segn. Aperte</p>
</div>
</div>
</div>
</div>
</div>
<h3 class="mb-3">Gestione Sistema</h3>
<div class="row">
<div class="col-12 col-md-4 mb-3">
<NuxtLink to="/admin/catalog" class="btn btn-outline-primary w-100 py-3 d-flex flex-column align-items-center">
<BIIcon name="it-list" size="sm" color="primary" class="mb-2" />
Categorie e Team
</NuxtLink>
</div>
<div class="col-12 col-md-4 mb-3">
<NuxtLink to="/admin/areas" class="btn btn-outline-primary w-100 py-3 d-flex flex-column align-items-center">
<BIIcon name="it-map-marker" size="sm" color="primary" class="mb-2" />
Aree
</NuxtLink>
</div>
<div class="col-12 col-md-4 mb-3">
<NuxtLink to="/admin/users" class="btn btn-outline-primary w-100 py-3 d-flex flex-column align-items-center">
<BIIcon name="it-user" size="sm" color="primary" class="mb-2" />
Gestione Utenti
</NuxtLink>
</div>
<div class="col-12 col-md-6 mb-3">
<NuxtLink to="/admin/moderation" class="btn btn-outline-danger w-100 py-3 d-flex flex-column align-items-center">
<BIIcon name="it-warning" size="sm" color="danger" class="mb-2" />
Moderazione Abusi
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</template> </template>
<style scoped>
.admin-action {
min-height: 44px;
text-align: left;
}
</style>
+21 -6
View File
@@ -1,8 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
const { getOpenReports, getAssignedReports } = useOperator() const { getOpenReports, getAssignedReports, takeReport } = useOperator()
const { data: openReportsRes } = useAsyncData('open-reports', () => getOpenReports({ per_page: 5 })) const { data: openReportsRes, refresh: refreshOpen } = useAsyncData('open-reports', () => getOpenReports({ per_page: 5 }))
const { data: assignedReportsRes } = useAsyncData('assigned-reports', () => getAssignedReports({ per_page: 5 })) const { data: assignedReportsRes, refresh: refreshAssigned } = useAsyncData('assigned-reports', () => getAssignedReports({ per_page: 5 }))
const processingIds = ref<number[]>([])
const handleTakeReport = async (id: number) => {
processingIds.value.push(id)
try {
const res = await takeReport(id)
if (res.ok) {
await Promise.all([refreshOpen(), refreshAssigned()])
}
} finally {
processingIds.value = processingIds.value.filter(pid => pid !== id)
}
}
</script> </script>
<template> <template>
@@ -11,8 +25,8 @@ const { data: assignedReportsRes } = useAsyncData('assigned-reports', () => getA
<div class="card card-bg h-100"> <div class="card card-bg h-100">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="h5 mb-0">Segnalazioni disponibili</h3> <h3 class="h5 mb-0">Da prendere in carico</h3>
<NuxtLink to="/operator" class="btn btn-link btn-sm pe-0">Apri elenco</NuxtLink> <NuxtLink to="/operator" class="btn btn-link btn-sm pe-0">Apri coda</NuxtLink>
</div> </div>
<div v-if="openReportsRes?.ok && openReportsRes.reports.length === 0" class="alert alert-info mb-0"> <div v-if="openReportsRes?.ok && openReportsRes.reports.length === 0" class="alert alert-info mb-0">
Nessuna nuova segnalazione in ingresso. Nessuna nuova segnalazione in ingresso.
@@ -20,8 +34,9 @@ const { data: assignedReportsRes } = useAsyncData('assigned-reports', () => getA
<ReportTable <ReportTable
v-else v-else
:reports="openReportsRes?.reports || []" :reports="openReportsRes?.reports || []"
action-variant="manage"
detail-base-path="/operator/reports" detail-base-path="/operator/reports"
action-label="Visualizza" @take="handleTakeReport"
/> />
</div> </div>
</div> </div>
+8 -12
View File
@@ -1,18 +1,10 @@
import type { import type {
AreaListResponse, AreaListResponse, CategoryListResponse, CreateAreaRequest, CreateCategoryRequest, CreateTeamRequest,
CategoryListResponse,
CreateAreaRequest,
CreateCategoryRequest,
CreateTeamRequest,
ModerationListResponse, ModerationListResponse,
OkResponse, OkResponse,
OverviewStatsResponse, OverviewStatsResponse, TeamListResponse, UpdateModerationRequest,
UpdateAreaRequest, UpdateAreaRequest, UpdateCategoryRequest, UpdateTeamRequest,
UpdateCategoryRequest, UpdateUserBanRequest, UpdateUserRolesRequest,
UpdateModerationRequest,
UpdateTeamRequest,
UpdateUserBanRequest,
UpdateUserRolesRequest,
UserListResponse UserListResponse
} from "~/types/api"; } from "~/types/api";
@@ -53,6 +45,9 @@ export const useAdmin = () => {
const deleteCategory = (id: number) => const deleteCategory = (id: number) =>
useApiFetch<OkResponse>(`/api/v1/admin/categories/${id}`, { method: 'DELETE' }) useApiFetch<OkResponse>(`/api/v1/admin/categories/${id}`, { method: 'DELETE' })
const getTeams = () =>
useApiFetch<TeamListResponse>('/api/v1/admin/teams', { method: 'GET' })
const createTeam = (body: CreateTeamRequest) => const createTeam = (body: CreateTeamRequest) =>
useApiFetch<OkResponse>('/api/v1/admin/teams', { method: 'POST', body }) useApiFetch<OkResponse>('/api/v1/admin/teams', { method: 'POST', body })
@@ -81,6 +76,7 @@ export const useAdmin = () => {
createCategory, createCategory,
updateCategory, updateCategory,
deleteCategory, deleteCategory,
getTeams,
createTeam, createTeam,
updateTeam, updateTeam,
deleteTeam, deleteTeam,
+2 -28
View File
@@ -29,10 +29,7 @@ export const useAuth = () => {
type UserContext = CurrentUserResponse['user'] type UserContext = CurrentUserResponse['user']
const user = useState<UserContext | null>('auth_user', () => null) const user = useState<UserContext | null>('auth_user', () => null)
const isSyncingUser = useState<boolean>('auth_syncing', () => false) const isSyncingUser = useState<boolean>('auth_syncing', () => false)
const unreadNotifications = useState<number>('auth_unread_notifications', () => 0)
const isRefreshingNotifications = useState<boolean>('auth_refreshing_notifications', () => false)
const isAuthenticated = computed(() => !!(tokenState.value || token.value)) const isAuthenticated = computed(() => !!(tokenState.value || token.value))
const hasUnreadNotifications = computed(() => unreadNotifications.value > 0)
const isAuthStatusError = (error: any) => { const isAuthStatusError = (error: any) => {
const status = error?.statusCode || error?.status || error?.response?.status const status = error?.statusCode || error?.status || error?.response?.status
return status === 401 || status === 403 return status === 401 || status === 403
@@ -42,24 +39,6 @@ export const useAuth = () => {
tokenState.value = newVal || null tokenState.value = newVal || null
}) })
const refreshUnreadNotifications = async () => {
if (!(tokenState.value || token.value) || !user.value || isRefreshingNotifications.value) {
unreadNotifications.value = user.value ? unreadNotifications.value : 0
return
}
isRefreshingNotifications.value = true
try {
const res = await fetchNotifications({ page: 1, per_page: 20 })
unreadNotifications.value = (res.notifications || []).filter(notification => !notification.read_at).length
} catch (error) {
console.error('[Auth] Failed to refresh notifications:', error)
} finally {
isRefreshingNotifications.value = false
}
}
const syncUser = async (options: { redirectOnSuccess?: boolean; currentPath?: string } = {}) => { const syncUser = async (options: { redirectOnSuccess?: boolean; currentPath?: string } = {}) => {
const { redirectOnSuccess = false, currentPath } = options const { redirectOnSuccess = false, currentPath } = options
const tokenVal = tokenState.value || token.value const tokenVal = tokenState.value || token.value
@@ -70,8 +49,7 @@ export const useAuth = () => {
const res = await fetchCurrentUser() const res = await fetchCurrentUser()
if (res.ok) { if (res.ok) {
user.value = res.user user.value = res.user
await nuxtApp.runWithContext(() => refreshUnreadNotifications()) const path = currentPath ?? useNuxtApp().$router.currentRoute.value.path
const path = currentPath ?? nuxtApp.$router.currentRoute.value.path
if (redirectOnSuccess && path.startsWith('/auth')) { if (redirectOnSuccess && path.startsWith('/auth')) {
await nuxtApp.runWithContext(() => navigateTo('/dashboard')) await nuxtApp.runWithContext(() => navigateTo('/dashboard'))
@@ -93,7 +71,7 @@ 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({ redirectOnSuccess: true })
window.location.reload(); window.location.reload();
} }
@@ -101,7 +79,6 @@ export const useAuth = () => {
token.value = null token.value = null
tokenState.value = null tokenState.value = null
user.value = null user.value = null
unreadNotifications.value = 0
nuxtApp.runWithContext(() => navigateTo('/auth/login')) nuxtApp.runWithContext(() => navigateTo('/auth/login'))
} }
@@ -144,9 +121,6 @@ export const useAuth = () => {
tokenState, tokenState,
isSyncingUser, isSyncingUser,
isAuthenticated, isAuthenticated,
unreadNotifications,
hasUnreadNotifications,
refreshUnreadNotifications,
handleLoginSuccess, handleLoginSuccess,
syncUser, syncUser,
logout, logout,
+1 -1
View File
@@ -9,7 +9,7 @@ export function useMembers() {
const members: Member[] = [ const members: Member[] = [
{ name: "Renato Loiacono", image: "/renatoloiacono.webp" }, { name: "Renato Loiacono", image: "/renatoloiacono.webp" },
{ name: "Michelangelo Parisi", image: "/michelangeloparisi.webp" }, { name: "Michelangelo Parisi", image: "/michelangeloparisi.webp" },
{ name: "Paolo Gabriele Schiraldi", image: "/paologabrieleschiraldi.webp" }, { name: "Gabriele Schiraldi", image: "/gabrielepaoloschiraldi.webp" },
]; ];
const sortedMembers = computed(() => { const sortedMembers = computed(() => {
-4
View File
@@ -39,9 +39,6 @@ export const useOperator = () => {
const revokeInterventionLink = (id: number) => const revokeInterventionLink = (id: number) =>
useApiFetch<OkResponse>(`/api/v1/reports/${id}/intervention-link`, { method: 'DELETE' }) useApiFetch<OkResponse>(`/api/v1/reports/${id}/intervention-link`, { method: 'DELETE' })
const getPublicIntervention = (token: string) =>
useApiFetch<any>(`/api/v1/interventions/${token}`, { method: 'GET' })
return { return {
getOpenReports, getOpenReports,
getAssignedReports, getAssignedReports,
@@ -53,6 +50,5 @@ export const useOperator = () => {
getInterventionSummary, getInterventionSummary,
createInterventionLink, createInterventionLink,
revokeInterventionLink, revokeInterventionLink,
getPublicIntervention,
} }
} }
+4 -14
View File
@@ -1,21 +1,15 @@
import type { import type {
CategoryListResponse, CategoryListResponse,
CreateReportRequest, CreateReportRequest,
CreateReportResponse, CreateReportResponse, FeedbackRequest, FlagRequest, FlagResponse,
FeedbackRequest,
FlagRequest,
FlagResponse,
MediaUploadResponse, MediaUploadResponse,
MessageListResponse, MessageListResponse, MessageRequest, OkResponse,
MessageRequest,
OkResponse,
ReportDetailResponse, ReportDetailResponse,
ReportListResponse, ReportListResponse
TeamListResponse
} from "~/types/api"; } from "~/types/api";
export const useReports = () => { export const useReports = () => {
const getReports = (query?: { area_id?: number; category_id?: number; status?: string; page?: number; per_page?: number }) => const getReports = (query?: { area_id?: number; category_id?: number; status?: string }) =>
useApiFetch<ReportListResponse>('/api/v1/reports', { method: 'GET', query }) useApiFetch<ReportListResponse>('/api/v1/reports', { method: 'GET', query })
const getCategories = () => const getCategories = () =>
@@ -45,9 +39,6 @@ export const useReports = () => {
const flagMessage = (reportId: number, messageId: number, body: FlagRequest) => const flagMessage = (reportId: number, messageId: number, body: FlagRequest) =>
useApiFetch<FlagResponse>(`/api/v1/reports/${reportId}/messages/${messageId}/flags`, { method: 'POST', body }) useApiFetch<FlagResponse>(`/api/v1/reports/${reportId}/messages/${messageId}/flags`, { method: 'POST', body })
const getTeamSuggestions = (id: number) =>
useApiFetch<TeamListResponse>(`/api/v1/reports/${id}/team-suggestions`, { method: 'GET' })
return { return {
getReports, getReports,
getCategories, getCategories,
@@ -59,6 +50,5 @@ export const useReports = () => {
addFeedback, addFeedback,
flagReport, flagReport,
flagMessage, flagMessage,
getTeamSuggestions,
} }
} }
+9 -32
View File
@@ -21,7 +21,6 @@ const formData = reactive({
}) })
const jsonError = ref('') const jsonError = ref('')
const actionError = ref('') const actionError = ref('')
const modalError = ref('')
const deletingAreaId = ref<number | null>(null) const deletingAreaId = ref<number | null>(null)
const areaPendingDelete = ref<any | null>(null) const areaPendingDelete = ref<any | null>(null)
@@ -122,15 +121,13 @@ const renderExistingAreasInModal = (L: any, currentMap: any) => {
}, },
interactive: false interactive: false
}).addTo(currentMap) }).addTo(currentMap)
} catch (e) { } catch(e) {}
}
} }
}) })
} }
const openCreateModal = () => { const openCreateModal = () => {
actionError.value = '' actionError.value = ''
modalError.value = ''
editingAreaId.value = null editingAreaId.value = null
formData.name = '' formData.name = ''
formData.description = '' formData.description = ''
@@ -174,7 +171,6 @@ const openCreateModal = () => {
} }
jsonError.value = '' jsonError.value = ''
modalError.value = ''
mapPoints.value.push([e.latlng.lat, e.latlng.lng]) mapPoints.value.push([e.latlng.lat, e.latlng.lng])
polygon.setLatLngs(mapPoints.value) polygon.setLatLngs(mapPoints.value)
updateGeojson() updateGeojson()
@@ -201,7 +197,6 @@ const loadPolygonFromGeoJson = (boundaryGeojson: any) => {
const openEditModal = (area: any) => { const openEditModal = (area: any) => {
actionError.value = '' actionError.value = ''
modalError.value = ''
editingAreaId.value = area.id editingAreaId.value = area.id
formData.name = area.name || '' formData.name = area.name || ''
formData.description = area.description || '' formData.description = area.description || ''
@@ -255,7 +250,6 @@ const openEditModal = (area: any) => {
} }
jsonError.value = '' jsonError.value = ''
modalError.value = ''
mapPoints.value.push([e.latlng.lat, e.latlng.lng]) mapPoints.value.push([e.latlng.lat, e.latlng.lng])
polygon.setLatLngs(mapPoints.value) polygon.setLatLngs(mapPoints.value)
updateGeojson() updateGeojson()
@@ -269,7 +263,6 @@ const clearMap = () => {
polygon.setLatLngs([]) polygon.setLatLngs([])
} }
formData.geojson = '' formData.geojson = ''
modalError.value = ''
} }
const updateGeojson = () => { const updateGeojson = () => {
@@ -277,7 +270,7 @@ const updateGeojson = () => {
formData.geojson = '' formData.geojson = ''
return return
} }
const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number]) const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number]) // Lng, Lat
if (coords[0]) { if (coords[0]) {
coords.push([...coords[0]]) coords.push([...coords[0]])
} }
@@ -304,7 +297,6 @@ onBeforeUnmount(() => {
const handleCreate = async () => { const handleCreate = async () => {
jsonError.value = '' jsonError.value = ''
modalError.value = ''
let parsedGeojson = null let parsedGeojson = null
try { try {
@@ -339,16 +331,9 @@ const handleCreate = async () => {
closeCreateModal() closeCreateModal()
actionError.value = '' actionError.value = ''
} }
} catch (err: any) { } catch (err) {
console.error('Failed to save area', err) console.error('Failed to save area', err)
actionError.value = 'Errore durante il salvataggio dell\'area.'
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.";
}
} }
} }
@@ -394,7 +379,7 @@ const handleDeleteArea = async () => {
<div> <div>
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Torna al pannello admin</NuxtLink> <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"> <button class="btn btn-primary btn-sm" @click="openCreateModal">
<BIIcon class="me-1" name="it-plus" size="xs"/> <BIIcon name="it-plus" size="xs" class="me-1" />
Nuova Area Nuova Area
</button> </button>
</div> </div>
@@ -456,8 +441,7 @@ const handleDeleteArea = async () => {
</div> </div>
</template> </template>
<div v-if="isCreating" class="modal-backdrop fade show d-block" <div v-if="isCreating" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
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 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-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
@@ -466,17 +450,12 @@ const handleDeleteArea = async () => {
<button type="button" class="btn-close" @click="closeCreateModal"></button> <button type="button" class="btn-close" @click="closeCreateModal"></button>
</div> </div>
<div class="modal-body"> <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"> <form @submit.prevent="handleCreate">
<div class="row"> <div class="row">
<div class="col-md-5"> <div class="col-md-5">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Nome Area</label> <label class="form-label">Nome Area</label>
<input v-model="formData.name" class="form-control" placeholder="Es. Centro Storico" required <input type="text" class="form-control" v-model="formData.name" required placeholder="Es. Centro Storico" />
type="text"/>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Descrizione (opzionale)</label> <label class="form-label">Descrizione (opzionale)</label>
@@ -504,8 +483,7 @@ const handleDeleteArea = async () => {
</button> </button>
</div> </div>
<div class="alert alert-light border small mb-2 p-2"> <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 Clicca sulla mappa per aggiungere vertici al poligono. Sono necessari almeno 3 punti. Sono ammessi solo punti nel Comune di Bari.
solo punti nel Comune di Bari.
</div> </div>
<div id="areaMap" class="rounded border" style="height: 400px; width: 100%; z-index: 1;"></div> <div id="areaMap" class="rounded border" style="height: 400px; width: 100%; z-index: 1;"></div>
</div> </div>
@@ -514,8 +492,7 @@ const handleDeleteArea = async () => {
</div> </div>
<div class="modal-footer d-flex justify-content-end gap-2"> <div class="modal-footer d-flex justify-content-end gap-2">
<button type="button" class="btn btn-outline-secondary" @click="closeCreateModal">Annulla</button> <button type="button" class="btn btn-outline-secondary" @click="closeCreateModal">Annulla</button>
<button :disabled="!formData.name || !formData.geojson" class="btn btn-primary" type="button" <button type="button" class="btn btn-primary" :disabled="!formData.name || !formData.geojson" @click="handleCreate">
@click="handleCreate">
{{ submitLabel }} {{ submitLabel }}
</button> </button>
</div> </div>
+38 -62
View File
@@ -8,14 +8,10 @@ definePageMeta({
useHead({ title: 'Categorie e Team - Admin' }) useHead({ title: 'Categorie e Team - Admin' })
const { createCategory, updateCategory, deleteCategory, createTeam, updateTeam, deleteTeam, getAreas } = useAdmin() const { createCategory, updateCategory, deleteCategory, getTeams, createTeam, updateTeam, deleteTeam, getAreas } = useAdmin()
const { getCategories } = useReports() const { getCategories } = useReports()
const { getTeams } = useOperator()
const { const { data: categoriesRes, refresh: refreshCategories } = await useAsyncData('report-categories-admin-view', () => getCategories())
data: categoriesRes,
refresh: refreshCategories
} = await useAsyncData('report-categories-admin-view', () => getCategories())
const { data: teamsRes, refresh: refreshTeams } = await useAsyncData('admin-teams', () => getTeams()) const { data: teamsRes, refresh: refreshTeams } = await useAsyncData('admin-teams', () => getTeams())
const { data: areasRes } = await useAsyncData('admin-team-areas', () => getAreas()) const { data: areasRes } = await useAsyncData('admin-team-areas', () => getAreas())
@@ -239,7 +235,7 @@ const handleDeleteTeam = async () => {
<form class="mb-4" @submit.prevent="submitCategory"> <form class="mb-4" @submit.prevent="submitCategory">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Nome categoria</label> <label class="form-label">Nome categoria</label>
<input v-model="categoryForm.name" class="form-control" required type="text"/> <input v-model="categoryForm.name" type="text" class="form-control" required />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Descrizione</label> <label class="form-label">Descrizione</label>
@@ -251,8 +247,8 @@ const handleDeleteTeam = async () => {
</button> </button>
<button <button
v-if="editingCategoryId" v-if="editingCategoryId"
class="btn btn-outline-secondary ms-2"
type="button" type="button"
class="btn btn-outline-secondary ms-2"
@click="resetCategoryForm" @click="resetCategoryForm"
> >
Annulla Annulla
@@ -260,7 +256,7 @@ const handleDeleteTeam = async () => {
</form> </form>
<div class="table-responsive report-table-wrapper"> <div class="table-responsive report-table-wrapper">
<table class="table table-hover report-table mb-0 align-middle"> <table class="table table-hover report-table mb-0">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -282,32 +278,28 @@ const handleDeleteTeam = async () => {
</span> </span>
</td> </td>
<td class="text-end"> <td class="text-end">
<div class="d-flex justify-content-end gap-2"> <button class="btn btn-sm btn-outline-primary me-2" @click="editCategory(category)">
<button class="btn btn-outline-primary btn-icon" title="Modifica" @click="editCategory(category)"> Modifica
<Icon name="lucide:pencil" size="16"/>
</button> </button>
<button <button
class="btn btn-sm"
:class="category.active === false ? 'btn-outline-success' : 'btn-outline-secondary'" :class="category.active === false ? 'btn-outline-success' : 'btn-outline-secondary'"
:title="category.active === false ? 'Attiva' : 'Disattiva'"
class="btn btn-icon"
@click="toggleCategoryActive(category)" @click="toggleCategoryActive(category)"
> >
<Icon :name="category.active === false ? 'lucide:check-circle' : 'lucide:ban'" size="16"/> {{ category.active === false ? 'Attiva' : 'Disattiva' }}
</button> </button>
<button <button
class="btn btn-sm btn-outline-danger ms-2"
:disabled="deletingCategoryId === category.id" :disabled="deletingCategoryId === category.id"
class="btn btn-outline-danger btn-icon"
title="Elimina"
@click="askDeleteCategory(category)" @click="askDeleteCategory(category)"
> >
<span v-if="deletingCategoryId === category.id" class="spinner-border spinner-icon"></span> <span v-if="deletingCategoryId === category.id" class="spinner-border spinner-border-sm me-1"></span>
<Icon v-else name="lucide:trash-2" size="16"/> Elimina
</button> </button>
</div>
</td> </td>
</tr> </tr>
<tr v-if="categories.length === 0"> <tr v-if="categories.length === 0">
<td class="text-muted text-center py-4" colspan="4">Nessuna categoria configurata.</td> <td colspan="4" class="text-muted">Nessuna categoria configurata.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -331,7 +323,7 @@ const handleDeleteTeam = async () => {
<form class="row g-3 mb-4" @submit.prevent="submitTeam"> <form class="row g-3 mb-4" @submit.prevent="submitTeam">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label class="form-label">Nome team</label> <label class="form-label">Nome team</label>
<input v-model="teamForm.name" class="form-control" required type="text"/> <input v-model="teamForm.name" type="text" class="form-control" required />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label class="form-label">Categoria coperta</label> <label class="form-label">Categoria coperta</label>
@@ -353,18 +345,17 @@ const handleDeleteTeam = async () => {
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label class="form-label">Descrizione</label> <label class="form-label">Descrizione</label>
<input v-model="teamForm.description" class="form-control" type="text"/> <input v-model="teamForm.description" type="text" class="form-control" />
</div> </div>
<div class="col-12"> <div class="col-12">
<button :disabled="isSavingTeam || !teamForm.name.trim() || !teamForm.team_category_id" class="btn btn-primary" <button type="submit" class="btn btn-primary" :disabled="isSavingTeam || !teamForm.name.trim() || !teamForm.team_category_id">
type="submit">
<span v-if="isSavingTeam" class="spinner-border spinner-border-sm me-2"></span> <span v-if="isSavingTeam" class="spinner-border spinner-border-sm me-2"></span>
{{ editingTeamId ? 'Salva team' : 'Crea team' }} {{ editingTeamId ? 'Salva team' : 'Crea team' }}
</button> </button>
<button <button
v-if="editingTeamId" v-if="editingTeamId"
class="btn btn-outline-secondary ms-2"
type="button" type="button"
class="btn btn-outline-secondary ms-2"
@click="resetTeamForm" @click="resetTeamForm"
> >
Annulla Annulla
@@ -373,7 +364,7 @@ const handleDeleteTeam = async () => {
</form> </form>
<div class="table-responsive report-table-wrapper"> <div class="table-responsive report-table-wrapper">
<table class="table table-hover report-table mb-0 align-middle"> <table class="table table-hover report-table mb-0">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -399,32 +390,28 @@ const handleDeleteTeam = async () => {
</span> </span>
</td> </td>
<td class="text-end"> <td class="text-end">
<div class="d-flex justify-content-end gap-2"> <button class="btn btn-sm btn-outline-primary me-2" @click="editTeam(team)">
<button class="btn btn-outline-primary btn-icon" title="Modifica" @click="editTeam(team)"> Modifica
<Icon name="lucide:pencil" size="16"/>
</button> </button>
<button <button
class="btn btn-sm"
:class="team.active === false ? 'btn-outline-success' : 'btn-outline-secondary'" :class="team.active === false ? 'btn-outline-success' : 'btn-outline-secondary'"
:title="team.active === false ? 'Attiva' : 'Disattiva'"
class="btn btn-icon"
@click="toggleTeamActive(team)" @click="toggleTeamActive(team)"
> >
<Icon :name="team.active === false ? 'lucide:check-circle' : 'lucide:ban'" size="16"/> {{ team.active === false ? 'Attiva' : 'Disattiva' }}
</button> </button>
<button <button
class="btn btn-sm btn-outline-danger ms-2"
:disabled="deletingTeamId === team.id" :disabled="deletingTeamId === team.id"
class="btn btn-outline-danger btn-icon"
title="Elimina"
@click="askDeleteTeam(team)" @click="askDeleteTeam(team)"
> >
<span v-if="deletingTeamId === team.id" class="spinner-border spinner-icon"></span> <span v-if="deletingTeamId === team.id" class="spinner-border spinner-border-sm me-1"></span>
<Icon v-else name="lucide:trash-2" size="16"/> Elimina
</button> </button>
</div>
</td> </td>
</tr> </tr>
<tr v-if="teams.length === 0"> <tr v-if="teams.length === 0">
<td class="text-muted text-center py-4" colspan="6">Nessun team configurato.</td> <td colspan="6" class="text-muted">Nessun team configurato.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -435,43 +422,32 @@ const handleDeleteTeam = async () => {
</div> </div>
<ConfirmModal <ConfirmModal
:message="categoryDeleteMessage"
:open="categoryPendingDelete !== null" :open="categoryPendingDelete !== null"
:pending="deletingCategoryId !== null"
confirm-class="btn-danger"
confirm-label="Elimina categoria"
title="Elimina categoria" title="Elimina categoria"
:message="categoryDeleteMessage"
confirm-label="Elimina categoria"
confirm-class="btn-danger"
:pending="deletingCategoryId !== null"
@cancel="closeDeleteCategoryModal" @cancel="closeDeleteCategoryModal"
@confirm="handleDeleteCategory" @confirm="handleDeleteCategory"
/> />
<ConfirmModal <ConfirmModal
:message="teamDeleteMessage"
:open="teamPendingDelete !== null" :open="teamPendingDelete !== null"
:pending="deletingTeamId !== null"
confirm-class="btn-danger"
confirm-label="Elimina team"
title="Elimina team" title="Elimina team"
:message="teamDeleteMessage"
confirm-label="Elimina team"
confirm-class="btn-danger"
:pending="deletingTeamId !== null"
@cancel="closeDeleteTeamModal" @cancel="closeDeleteTeamModal"
@confirm="handleDeleteTeam" @confirm="handleDeleteTeam"
/> />
</div> </div>
</template> </template>
<style scoped> <style>
.btn-icon { .text-end .btn {
width: 32px; margin: 0 !important;
height: 32px; width: 120px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.spinner-icon {
width: 14px;
height: 14px;
border-width: 2px;
} }
</style> </style>
+5 -20
View File
@@ -60,7 +60,6 @@ const handleResolve = async () => {
resolution_note: resolveModal.note || undefined resolution_note: resolveModal.note || undefined
}) })
resolveModal.pending = false
await refresh() await refresh()
closeResolveModal() closeResolveModal()
} catch (err) { } catch (err) {
@@ -91,7 +90,6 @@ const handleDismiss = async () => {
try { try {
await updateModerationFlag(dismissModal.flagId, { status: 'DISMISSED' }) await updateModerationFlag(dismissModal.flagId, { status: 'DISMISSED' })
dismissModal.pending = false
await refresh() await refresh()
closeDismissModal() closeDismissModal()
} catch (err) { } catch (err) {
@@ -108,19 +106,6 @@ const formatDate = (dateStr: string) => {
hour: '2-digit', minute: '2-digit' hour: '2-digit', minute: '2-digit'
}) })
} }
const formatFlagStatus = (status: string) => {
const map: Record<string, string> = {
OPEN: 'Aperto',
RESOLVED: 'Risolto',
DISMISSED: 'Ignorato'
}
return map[status] || status
}
const contentTypeLabel = (type: string) => type === 'REPORT' ? 'Segnalazione' : 'Messaggio'
const canRejectReport = (flag: any) => flag.content_type === 'REPORT'
</script> </script>
<template> <template>
@@ -168,7 +153,7 @@ const canRejectReport = (flag: any) => flag.content_type === 'REPORT'
'bg-warning': flag.status === 'OPEN', 'bg-warning': flag.status === 'OPEN',
'bg-success': flag.status === 'RESOLVED', 'bg-success': flag.status === 'RESOLVED',
'bg-secondary': flag.status === 'DISMISSED' 'bg-secondary': flag.status === 'DISMISSED'
}">{{ formatFlagStatus(flag.status) }}</span> }">{{ flag.status }}</span>
</div> </div>
<small class="text-muted">{{ formatDate(flag.created_at) }}</small> <small class="text-muted">{{ formatDate(flag.created_at) }}</small>
</div> </div>
@@ -181,7 +166,7 @@ const canRejectReport = (flag: any) => flag.content_type === 'REPORT'
</p> </p>
<div class="mt-info"> <div class="mt-info">
<span class="badge bg-info me-2">{{ contentTypeLabel(flag.content_type) }}</span> <span class="badge bg-info me-2">{{ flag.content_type }}</span>
<span v-if="flag.report_id"> <span v-if="flag.report_id">
Rif: Rif:
<NuxtLink :to="`/operator/reports/${flag.report_id}`" class="text-decoration-none"> <NuxtLink :to="`/operator/reports/${flag.report_id}`" class="text-decoration-none">
@@ -205,11 +190,11 @@ const canRejectReport = (flag: any) => flag.content_type === 'REPORT'
<div class="col-md-4 text-end" v-if="flag.status === 'OPEN'"> <div class="col-md-4 text-end" v-if="flag.status === 'OPEN'">
<div class="d-flex flex-column gap-2 mt-3 mt-md-0"> <div class="d-flex flex-column gap-2 mt-3 mt-md-0">
<button class="btn btn-outline-danger btn-sm" @click="openResolveModal(flag.id, canRejectReport(flag))"> <button class="btn btn-outline-danger btn-sm" @click="openResolveModal(flag.id, true)">
<BIIcon name="it-ban" size="xs" /> {{ canRejectReport(flag) ? 'Risolvi & Rigetta Segn.' : 'Risolvi flag' }} <BIIcon name="it-ban" size="xs" /> Risolvi & Rigetta Segn.
</button> </button>
<button class="btn btn-outline-warning btn-sm" @click="openResolveModal(flag.id, false)"> <button class="btn btn-outline-warning btn-sm" @click="openResolveModal(flag.id, false)">
<BIIcon name="it-check" size="xs" /> {{ canRejectReport(flag) ? 'Risolvi (Solo Avviso)' : 'Risolvi senza rigetto' }} <BIIcon name="it-check" size="xs" /> Risolvi (Solo Avviso)
</button> </button>
<button class="btn btn-outline-secondary btn-sm" @click="openDismissModal(flag.id)"> <button class="btn btn-outline-secondary btn-sm" @click="openDismissModal(flag.id)">
<BIIcon name="it-close" size="xs" /> Ignora Flag <BIIcon name="it-close" size="xs" /> Ignora Flag
-258
View File
@@ -1,258 +0,0 @@
<script setup lang="ts">
import type { Report, ReportStatus } from '~/types/api'
definePageMeta({
middleware: 'auth',
auth: { role: 'ADMIN' }
})
useHead({ title: 'Overview Segnalazioni - Admin' })
const { getReports, getCategories } = useReports()
const { getAreas } = useAdmin()
const search = ref('')
const selectedStatus = ref<'ALL' | ReportStatus>('ALL')
const selectedCategoryId = ref<number | 'ALL'>('ALL')
const selectedAreaId = ref<number | 'ALL'>('ALL')
const { data: reportsRes, pending } = await useAsyncData('admin-report-overview', () => getReports())
const { data: categoriesRes } = await useAsyncData('admin-report-overview-categories', () => getCategories())
const { data: areasRes } = await useAsyncData('admin-report-overview-areas', () => getAreas())
const reports = computed<Report[]>(() => reportsRes.value?.reports || [])
const categories = computed(() => categoriesRes.value?.categories || [])
const areas = computed(() => areasRes.value?.areas || [])
const statusMeta: Record<ReportStatus, { label: string; tone: string }> = {
OPEN: { label: 'Aperta', tone: 'danger' },
UNDER_REVIEW: { label: 'In revisione', tone: 'warning' },
ASSIGNED: { label: 'Assegnata', tone: 'info' },
IN_PROGRESS: { label: 'In corso', tone: 'primary' },
RESOLVED: { label: 'Risolta', tone: 'success' },
REJECTED: { label: 'Rigettata', tone: 'secondary' },
CLOSED: { label: 'Chiusa', tone: 'dark' }
}
const filteredReports = computed(() => {
const term = search.value.trim().toLowerCase()
return reports.value.filter((report) => {
const matchesStatus = selectedStatus.value === 'ALL' || report.status === selectedStatus.value
const matchesCategory = selectedCategoryId.value === 'ALL' || report.category_id === selectedCategoryId.value
const matchesArea = selectedAreaId.value === 'ALL' || (report.area_id ?? null) === selectedAreaId.value
const matchesSearch = !term || [
report.id,
report.address,
report.description,
report.category_name,
report.area_name,
report.assigned_operator_id ? `#${report.assigned_operator_id}` : ''
].some((value) => String(value || '').toLowerCase().includes(term))
return matchesStatus && matchesCategory && matchesArea && matchesSearch
})
})
const totalReports = computed(() => reports.value.length)
const openReports = computed(() => reports.value.filter((report) => report.status === 'OPEN').length)
const activeReports = computed(() => reports.value.filter((report) => ['UNDER_REVIEW', 'ASSIGNED', 'IN_PROGRESS'].includes(report.status)).length)
const closedReports = computed(() => reports.value.filter((report) => ['RESOLVED', 'CLOSED'].includes(report.status)).length)
const reportsByStatus = computed(() =>
Object.entries(statusMeta)
.map(([status, meta]) => ({
status: status as ReportStatus,
label: meta.label,
tone: meta.tone,
total: reports.value.filter((report) => report.status === status).length
}))
.filter((item) => item.total > 0)
)
const currentPage = ref(1)
const itemsPerPage = ref(12)
watch([search, selectedStatus, selectedCategoryId, selectedAreaId], () => {
currentPage.value = 1
})
const totalPages = computed(() => Math.ceil(filteredReports.value.length / itemsPerPage.value))
const paginatedReports = computed(() => {
const sorted = [...filteredReports.value].sort(
(a, b) => new Date(b.created_at || '').getTime() - new Date(a.created_at || '').getTime()
)
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return sorted.slice(start, end)
})
const handlePageChange = (page: number) => {
currentPage.value = page
}
const formatDate = (value?: string) => {
if (!value) return '-'
return new Date(value).toLocaleString('it-IT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const truncate = (value?: string, max = 120) => {
if (!value) return '-'
return value.length > max ? `${value.slice(0, max).trim()}...` : value
}
</script>
<template>
<div class="container my-5">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<div>
<h1 class="h3 font-weight-bold mb-1">Overview Segnalazioni</h1>
<p class="text-muted mb-0">Vista completa in sola lettura per monitorare carico, stato e distribuzione.</p>
</div>
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm">Torna al pannello admin</NuxtLink>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3" v-for="card in [
{ label: 'Totali', value: totalReports, tone: 'primary' },
{ label: 'Aperte', value: openReports, tone: 'danger' },
{ label: 'In lavorazione', value: activeReports, tone: 'warning' },
{ label: 'Chiuse/Risolte', value: closedReports, tone: 'success' }
]" :key="card.label">
<div class="card card-bg dashboard-stat h-100">
<div class="card-body py-3">
<div class="stat-value">{{ card.value }}</div>
<div class="small text-muted">{{ card.label }}</div>
</div>
</div>
</div>
</div>
<div class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-4">
<label class="form-label">Cerca</label>
<input v-model="search" type="text" class="form-control" placeholder="ID, indirizzo, descrizione, area..." />
</div>
<div class="col-12 col-md-4 col-lg-3">
<label class="form-label">Stato</label>
<select v-model="selectedStatus" class="form-select">
<option value="ALL">Tutti</option>
<option v-for="item in reportsByStatus" :key="item.status" :value="item.status">{{ item.label }}</option>
</select>
</div>
<div class="col-12 col-md-4 col-lg-2">
<label class="form-label">Categoria</label>
<select v-model="selectedCategoryId" class="form-select">
<option value="ALL">Tutte</option>
<option v-for="category in categories" :key="category.id" :value="category.id">{{ category.name }}</option>
</select>
</div>
<div class="col-12 col-md-4 col-lg-3">
<label class="form-label">Area</label>
<select v-model="selectedAreaId" class="form-select">
<option value="ALL">Tutte</option>
<option v-for="area in areas" :key="area.id" :value="area.id">{{ area.name }}</option>
</select>
</div>
</div>
</div>
</div>
<div v-if="pending" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
<template v-else>
<div class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h2 class="h5 mb-0">Distribuzione stati</h2>
<span class="text-muted small">{{ filteredReports.length }} elementi trovati</span>
</div>
<div class="table-responsive report-table-wrapper">
<table class="table table-hover report-table mb-0 align-middle">
<thead>
<tr>
<th>Stato</th>
<th class="text-end">Totale</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportsByStatus" :key="item.status">
<td>
<span class="badge" :class="`bg-${item.tone}`">
{{ item.label }}
</span>
</td>
<td class="text-end">{{ item.total }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="filteredReports.length === 0" class="alert alert-info">
Nessuna segnalazione trovata con filtri correnti.
</div>
<div v-else class="card card-bg border-0 shadow-sm mb-5">
<div class="card-body p-0">
<div class="table-responsive report-table-wrapper">
<table class="table table-hover report-table mb-0 align-middle">
<thead>
<tr>
<th>ID</th>
<th>Categoria</th>
<th>Indirizzo</th>
<th>Area</th>
<th>Stato</th>
<th>Operatore</th>
<th>Aggiornata</th>
</tr>
</thead>
<tbody>
<tr v-for="report in paginatedReports" :key="report.id">
<td class="report-table-id">#{{ report.id }}</td>
<td>
<div class="fw-semibold">{{ report.category_name || '-' }}</div>
<small class="text-muted">{{ truncate(report.description, 72) }}</small>
</td>
<td>{{ report.address || '-' }}</td>
<td>{{ report.area_name || 'Non assegnata' }}</td>
<td>
<span class="badge" :class="`bg-${statusMeta[report.status].tone}`">
{{ statusMeta[report.status].label }}
</span>
</td>
<td>{{ report.assigned_operator_id ? `#${report.assigned_operator_id}` : '-' }}</td>
<td class="text-muted text-nowrap">{{ formatDate(report.updated_at || report.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer bg-transparent d-flex flex-wrap justify-content-between align-items-center py-3" v-if="totalPages > 1">
<span class="text-muted small mb-2 mb-sm-0">Pagina {{ currentPage }} di {{ totalPages }}</span>
<Pagination
:current-page="currentPage"
:per-page="itemsPerPage"
:total-items="filteredReports.length"
@page-change="handlePageChange"
/>
</div>
</div>
</template>
</div>
</template>
+11 -45
View File
@@ -16,21 +16,6 @@ const state = computed(() => {
return typeof value === 'string' ? value : '' return typeof value === 'string' ? value : ''
}) })
const providerError = computed(() => {
const value = route.query.error
return typeof value === 'string' ? value : ''
})
const providerCieError = computed(() => {
const value = route.query.cie_error
return typeof value === 'string' ? value : ''
})
const providerErrorDescription = computed(() => {
const value = route.query.error_description
return typeof value === 'string' ? value : ''
})
const isLoading = ref(true) const isLoading = ref(true)
const errorMessage = ref('') const errorMessage = ref('')
const statusMessage = ref('Verifica dell’autenticazione CIE in corso...') const statusMessage = ref('Verifica dell’autenticazione CIE in corso...')
@@ -50,35 +35,10 @@ const scheduleNextPoll = () => {
pollTimer = setTimeout(checkSession, 2000) pollTimer = setTimeout(checkSession, 2000)
} }
const redirectToLoginError = async (code: string, fallbackMessage: string) => {
stopPolling()
isLoading.value = false
try {
await router.replace({
path: '/auth/login',
query: { cie_error: code }
})
} catch {
errorMessage.value = fallbackMessage
}
}
const checkSession = async () => { const checkSession = async () => {
if (providerError.value || providerCieError.value) {
const isCancelled = providerErrorDescription.value.toLowerCase().includes('rejected by user')
|| providerError.value === 'access_denied'
|| providerCieError.value === 'invalid_request'
await redirectToLoginError(
isCancelled ? 'cancelled' : 'failed',
providerErrorDescription.value || 'Autenticazione CIE non completata. Riprova dal login.'
)
return
}
if (!state.value) { if (!state.value) {
await redirectToLoginError('invalid_session', 'Sessione CIE non valida: parametro state mancante.') isLoading.value = false
errorMessage.value = 'Sessione CIE non valida: parametro state mancante.'
return return
} }
@@ -94,19 +54,25 @@ const checkSession = async () => {
} }
if (['failed', 'expired', 'cancelled', 'error'].includes(res.status)) { if (['failed', 'expired', 'cancelled', 'error'].includes(res.status)) {
await redirectToLoginError('failed', 'Autenticazione CIE non completata. Riprova dal login.') stopPolling()
isLoading.value = false
errorMessage.value = 'Autenticazione CIE non completata. Riprova dal login.'
return return
} }
if (attempts >= maxAttempts) { if (attempts >= maxAttempts) {
await redirectToLoginError('timeout', 'Tempo di attesa scaduto durante la verifica CIE. Riprova.') stopPolling()
isLoading.value = false
errorMessage.value = 'Tempo di attesa scaduto durante la verifica CIE. Riprova.'
return return
} }
statusMessage.value = 'Autenticazione completata sul provider. Attendo conferma finale da Visione...' statusMessage.value = 'Autenticazione completata sul provider. Attendo conferma finale da Visione...'
scheduleNextPoll() scheduleNextPoll()
} catch (err: any) { } catch (err: any) {
await redirectToLoginError('verification_error', err.data?.message || 'Errore durante la verifica della sessione CIE.') stopPolling()
isLoading.value = false
errorMessage.value = err.data?.message || 'Errore durante la verifica della sessione CIE.'
} }
} }
+6 -48
View File
@@ -8,7 +8,6 @@ definePageMeta({
useHead({ title: 'Accedi - Visione' }) useHead({ title: 'Accedi - Visione' })
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const route = useRoute()
const { requestOtp, verifyOtp, handleLoginSuccess, startCieLogin } = useAuth() const { requestOtp, verifyOtp, handleLoginSuccess, startCieLogin } = useAuth()
const otpStep = ref<1 | 2>(1) const otpStep = ref<1 | 2>(1)
@@ -21,22 +20,6 @@ const otpCode = ref('')
const cieCallbackUrl = `${runtimeConfig.public.url}/auth/cie/callback` const cieCallbackUrl = `${runtimeConfig.public.url}/auth/cie/callback`
const cieErrorMessage = computed(() => {
const error = route.query.cie_error
if (typeof error !== 'string') return ''
const messages: Record<string, string> = {
invalid_session: 'Sessione CIE non valida o incompleta. Avvia di nuovo accesso con CIE.',
cancelled: 'Accesso con CIE annullato. Se vuoi, riprova dal pulsante Entra con CIE.',
failed: 'Autenticazione CIE non completata. Riprova dal pulsante Entra con CIE.',
timeout: 'Tempo di attesa scaduto durante verifica CIE. Riprova.',
verification_error: 'Errore durante verifica autenticazione CIE. Riprova tra poco.'
}
return messages[error] || 'Accesso con CIE non riuscito. Riprova.'
})
const handleRequestOtp = async () => { const handleRequestOtp = async () => {
isLoading.value = true isLoading.value = true
errorMessage.value = '' errorMessage.value = ''
@@ -77,10 +60,6 @@ const handleCieLogin = async () => {
isCieLoading.value = false isCieLoading.value = false
} }
} }
const handleOTPInput = async (value: string) => {
console.log(value)
}
</script> </script>
<template> <template>
@@ -101,12 +80,13 @@ const handleOTPInput = async (value: string) => {
<button <button
class="btn btn-primary w-100 mb-4 d-flex align-items-center justify-content-center gap-2 entraconcie" class="btn btn-primary w-100 mb-4 d-flex align-items-center justify-content-center gap-2"
:disabled="isCieLoading || isLoading" :disabled="isCieLoading || isLoading"
@click="handleCieLogin" @click="handleCieLogin"
> >
<span v-if="isCieLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span v-if="isCieLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
&#8203; <BIIcon name="it-card" size="sm" color="white" />
Entra con CIE
</button> </button>
<div class="d-flex align-items-center my-4"> <div class="d-flex align-items-center my-4">
@@ -115,10 +95,6 @@ const handleOTPInput = async (value: string) => {
<hr class="flex-grow-1"> <hr class="flex-grow-1">
</div> </div>
<div v-if="cieErrorMessage" class="alert alert-danger mb-4" role="alert">
{{ cieErrorMessage }}
</div>
<div v-if="errorMessage" class="alert alert-danger mb-4" role="alert"> <div v-if="errorMessage" class="alert alert-danger mb-4" role="alert">
{{ errorMessage }} {{ errorMessage }}
</div> </div>
@@ -130,27 +106,16 @@ const handleOTPInput = async (value: string) => {
</div> </div>
<button type="submit" class="btn btn-primary w-100" :disabled="isLoading"> <button type="submit" class="btn btn-primary w-100" :disabled="isLoading">
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2" role="status"></span> <span v-if="isLoading" class="spinner-border spinner-border-sm me-2" role="status"></span>
Entra Ricevi codice OTP
</button> </button>
</form> </form>
<form v-else @submit.prevent="handleVerifyOtp"> <form v-else @submit.prevent="handleVerifyOtp">
<div v-show="!errorMessage" class="alert alert-success mb-4 small"> <div class="alert alert-success mb-4 small">
Abbiamo inviato un codice OTP a <strong>{{ email }}</strong>. Abbiamo inviato un codice OTP a <strong>{{ email }}</strong>.
</div> </div>
<div class="form-group mb-4"> <div class="form-group mb-4">
<input <input type="text" class="form-control text-center text-tracking-widest fs-4" id="otp" v-model="otpCode" required maxlength="6" autocomplete="one-time-code">
id="otp"
v-model="otpCode"
autocomplete="one-time-code"
class="form-control text-center text-tracking-widest fs-4"
inputmode="numeric"
maxlength="6"
pattern="[0-9]*"
required
type="text"
@input="otpCode = otpCode.replace(/\D/g, '')"
>
<label for="otp" :class="{ 'active': otpCode.length > 0 }">Codice a 6 cifre</label> <label for="otp" :class="{ 'active': otpCode.length > 0 }">Codice a 6 cifre</label>
</div> </div>
<button type="submit" class="btn btn-primary w-100 mb-3" :disabled="isLoading"> <button type="submit" class="btn btn-primary w-100 mb-3" :disabled="isLoading">
@@ -182,11 +147,4 @@ const handleOTPInput = async (value: string) => {
.pe-5 { .pe-5 {
padding-right: 3rem !important; padding-right: 3rem !important;
} }
button.entraconcie {
background-image: url('/entraconcie.png');
background-repeat: no-repeat;
background-position: center center;
background-size: 100%;
}
</style> </style>
+24 -67
View File
@@ -3,8 +3,6 @@ import BIIcon from "~/components/BIIcon.vue";
const { getOpenDataReports } = usePublic() const { getOpenDataReports } = usePublic()
const { user } = useAuth();
const stats = { const stats = {
total_reports: 14523, total_reports: 14523,
resolved_reports: 13890, resolved_reports: 13890,
@@ -14,17 +12,6 @@ const stats = {
const { data: openDataResponse } = await useAsyncData('recent-fixes', () => const { data: openDataResponse } = await useAsyncData('recent-fixes', () =>
getOpenDataReports({ page: 1, per_page: 3 }) getOpenDataReports({ page: 1, per_page: 3 })
) )
const getStatusBadge = (status: string) => {
switch (status) {
case 'RESOLVED':
return { class: 'bg-success', text: 'Risolto', icon: 'it-check-circle' }
case 'CLOSED':
return { class: 'bg-secondary', text: 'Chiuso', icon: 'it-lock' }
default:
return { class: 'bg-info', text: status, icon: 'it-info-circle' }
}
}
</script> </script>
<template> <template>
@@ -36,11 +23,11 @@ const getStatusBadge = (status: string) => {
<div class="it-hero-text-wrapper p-4"> <div class="it-hero-text-wrapper p-4">
<h1 class="no_toc font-weight-bold">Miglioriamo insieme la città</h1> <h1 class="no_toc font-weight-bold">Miglioriamo insieme la città</h1>
<p class="d-none d-lg-block mt-3 mb-4 text-muted"> <p class="d-none d-lg-block mt-3 mb-4 text-muted">
Il portale ufficiale della città metropolitana di Bari per segnalare disservizi urbani. Scatta una foto, invia la segnalazione Il portale ufficiale nazionale per segnalare disservizi urbani. Scatta una foto, invia la segnalazione
e monitora l'intervento in totale trasparenza. e monitora l'intervento in totale trasparenza.
</p> </p>
<div class="it-btn-container d-flex justify-content-center gap-3"> <div class="it-btn-container d-flex justify-content-center gap-3">
<NuxtLink v-if="!user || isCitizen(user.role)" class="btn btn-primary" to="/auth/login"> <NuxtLink class="btn btn-primary" to="/auth/login">
Fai una segnalazione Fai una segnalazione
</NuxtLink> </NuxtLink>
<NuxtLink class="btn btn-outline-primary" to="/open-data"> <NuxtLink class="btn btn-outline-primary" to="/open-data">
@@ -58,24 +45,25 @@ const getStatusBadge = (status: string) => {
<div class="row text-center"> <div class="row text-center">
<div class="col-12 col-md-4 mb-4"> <div class="col-12 col-md-4 mb-4">
<div class="mb-3"> <div class="mb-3">
<BIIcon color="primary" name="it-camera" size="xl"/> <BIIcon name="it-camera" size="xl" color="primary" />
</div> </div>
<h3 class="h5 font-weight-semibold">1. Segnala</h3> <h3 class="h5 font-weight-semibold">1. Segnala</h3>
<p class="text-muted">Fotografa il problema stradale e condividi la tua posizione.</p> <p class="text-muted">Usa lo smartphone per fotografare il problema stradale e inviare la posizione
esatta.</p>
</div> </div>
<div class="col-12 col-md-4 mb-4"> <div class="col-12 col-md-4 mb-4">
<div class="mb-3"> <div class="mb-3">
<BIIcon color="primary" name="it-tool" size="xl"/> <BIIcon name="it-tool" size="xl" color="primary" />
</div> </div>
<h3 class="h5 font-weight-semibold">2. Intervento</h3> <h3 class="h5 font-weight-semibold">2. Intervento</h3>
<p class="text-muted">Il Comune attiva subito le squadre operative per risolvere il problema.</p> <p class="text-muted">Le squadre comunali prendono in carico la tua segnalazione e avviano i lavori.</p>
</div> </div>
<div class="col-12 col-md-4 mb-4"> <div class="col-12 col-md-4 mb-4">
<div class="mb-3"> <div class="mb-3">
<BIIcon color="primary" name="it-star-outline" size="xl"/> <BIIcon name="it-star-outline" size="xl" color="primary" />
</div> </div>
<h3 class="h5 font-weight-semibold">3. Punti e Feedback</h3> <h3 class="h5 font-weight-semibold">3. Punti e Feedback</h3>
<p class="text-muted">Problema risolto! Guadagna Punti Cittadino e valuta l'intervento.</p> <p class="text-muted">Problema risolto! Ottieni Punti Cittadino e lascia un feedback sull'intervento.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -114,40 +102,25 @@ const getStatusBadge = (status: string) => {
<div class="row" v-if="openDataResponse?.reports && openDataResponse.reports.length > 0"> <div class="row" v-if="openDataResponse?.reports && openDataResponse.reports.length > 0">
<div class="col-12 col-md-4 mb-4" v-for="report in openDataResponse.reports" :key="report.id"> <div class="col-12 col-md-4 mb-4" v-for="report in openDataResponse.reports" :key="report.id">
<div class="card-wrapper card-space h-100"> <div class="card-wrapper card-space">
<div class="card card-bg h-100 shadow-sm"> <div class="card card-bg card-big">
<div class="card-body d-flex flex-column"> <div class="card-body">
<div class="category-top">
<div class="d-flex justify-content-between align-items-start mb-3"> <BIIcon name="it-check-circle" size="sm" color="success" />
<div class="category-top m-0"> <span class="category text-success font-weight-semibold">Risolto</span>
<span :class="getStatusBadge(report.status).class"
class="badge rounded-pill text-white px-3 py-1">
<BIIcon :name="getStatusBadge(report.status).icon" class="me-1" color="white" size="xs"/>
{{ getStatusBadge(report.status).text }}
</span>
</div>
</div> </div>
<h3 class="card-title h5 mt-2">{{ report.category_name }}</h3>
<h3 class="card-title h5 font-weight-bold">{{ report.category_name }}</h3> <p class="card-text text-muted">
<span class="me-1"><BIIcon name="it-map-marker-circle" size="xs" /></span>
<p class="card-text text-muted mb-4"> {{ report.area_name || 'Area comunale' }}
<BIIcon class="me-1" name="it-map-marker-circle" size="sm"/>
{{ report.area_name || 'Area comunale non specificata' }}
</p> </p>
<div class="mt-auto pt-3 border-top"> <div class="it-card-footer mt-4">
<div class="row text-muted small"> <span class="card-signature">
<div class="col-6"> Aggiornato il {{ new Date(report.updated_at).toLocaleDateString('it-IT') }}
<div class="fw-bold text-uppercase">Segnalato</div> </span>
<div>{{ new Date(report.created_at).toLocaleDateString('it-IT') }}</div>
</div> </div>
<div class="col-6 text-end">
<div class="fw-bold text-uppercase">Risolto</div>
<div>{{ new Date(report.updated_at).toLocaleDateString('it-IT') }}</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -159,7 +132,7 @@ const getStatusBadge = (status: string) => {
</div> </div>
</div> </div>
<div v-show="!user" class="container my-5 pb-5"> <div class="container my-5 pb-5">
<div class="callout callout-highlight callout-primary"> <div class="callout callout-highlight callout-primary">
<div class="callout-title"> <div class="callout-title">
<BIIcon name="it-info-circle" /> <BIIcon name="it-info-circle" />
@@ -177,19 +150,3 @@ const getStatusBadge = (status: string) => {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.card-wrapper {
height: 100%;
}
.card-bg {
border: 1px solid rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card-bg:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important;
}
</style>
+64 -102
View File
@@ -1,11 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMembers } from "~/composables/useMembers";
useHead({ useHead({
title: 'Informazioni sul Progetto - Visione', title: 'Informazioni sul Progetto - Visione',
}) })
const { sortedMembers } = useMembers();
</script> </script>
<template> <template>
@@ -15,10 +11,10 @@ const { sortedMembers } = useMembers();
<div class="row"> <div class="row">
<div class="col-12 text-center"> <div class="col-12 text-center">
<div class="it-hero-text-wrapper p-4"> <div class="it-hero-text-wrapper p-4">
<h1 class="text-white font-weight-bold">Il Progetto "Visione"</h1> <h1 class="text-white font-weight-bold">Il Progetto Visione</h1>
<p class="text-white mt-3 lead font-weight-semibold"> <p class="text-white mt-3 lead font-weight-semibold">
La piattaforma digitale della Città Metropolitana di Bari per la partecipazione attiva, La piattaforma digitale per la gestione intelligente delle segnalazioni urbane,
la trasparenza e la gestione condivisa del territorio. pensata per avvicinare cittadini e pubblica amministrazione.
</p> </p>
</div> </div>
</div> </div>
@@ -29,20 +25,16 @@ const { sortedMembers } = useMembers();
<div class="container my-5 py-3"> <div class="container my-5 py-3">
<div class="row"> <div class="row">
<div class="col-12 col-lg-8 offset-lg-2"> <div class="col-12 col-lg-8 offset-lg-2">
<h2 class="mb-4 font-weight-bold">Un nuovo dialogo tra Cittadini ed Ente</h2> <h2 class="mb-4 font-weight-bold">Migliorare la qualità della vita in città</h2>
<p class="lead text-muted"> <p class="lead text-muted">
Il progetto nasce dall'esigenza di digitalizzare e ottimizzare i flussi di comunicazione tra la cittadinanza Visione nasce dall'esigenza di fornire alle amministrazioni comunali uno strumento moderno per mappare,
e la Pubblica Amministrazione. Sostituisce i tradizionali canali non tracciabili con un unico punto di gestire e risolvere disservizi presenti sul territorio (buche stradali, illuminazione guasta, rifiuti
accesso telematico. abbandonati).
</p> </p>
<p> <p>
L'adozione della piattaforma persegue un triplice obiettivo strategico: ridurre drasticamente i tempi di Il sistema non si limita a raccogliere dati, ma automatizza l'assegnazione alle squadre operative competenti
risposta tramite logiche geospaziali, traccia l'intero ciclo di vita della segnalazione e restituisce trasparenza ai
dell'Ente nella risoluzione dei disservizi (quali buche stradali, illuminazione guasta o degrado urbano), cittadini tramite un portale Open Data dedicato.
responsabilizzare la <strong>cittadinanza attiva</strong> tramite meccanismi di partecipazione diretta e
fornire
agli organi competenti un quadro analitico costantemente aggiornato e geolocalizzato delle criticità del
territorio.
</p> </p>
</div> </div>
</div> </div>
@@ -50,7 +42,7 @@ const { sortedMembers } = useMembers();
<div class="bg-light py-5"> <div class="bg-light py-5">
<div class="container"> <div class="container">
<h2 class="text-center mb-5 font-weight-bold">Come si articola l'ecosistema digitale</h2> <h2 class="text-center mb-5 font-weight-bold">Come funziona il sistema</h2>
<div class="row"> <div class="row">
<div class="col-12 col-md-4 mb-4"> <div class="col-12 col-md-4 mb-4">
@@ -58,17 +50,14 @@ const { sortedMembers } = useMembers();
<div class="card card-bg h-100"> <div class="card card-bg h-100">
<div class="card-body"> <div class="card-body">
<BIIcon class="mb-3" color="primary" name="it-user" size="xl"/> <BIIcon class="mb-3" color="primary" name="it-user" size="xl"/>
<h3 class="card-title h5 font-weight-bold">I Cittadini</h3> <h3 class="card-title h5 font-weight-bold">Cittadini</h3>
<p class="card-text text-muted"> <p class="card-text text-muted">
L'accesso è garantito attraverso l'autocompletamento anagrafico e il supporto ai sistemi di identità Tramite autenticazione sicura (OTP via email o CIE), i cittadini possono inviare segnalazioni
digitale geolocalizzate complete di foto.
nazionali (<strong>CIE</strong>).
<br><br> <br><br>
Il cittadino può compilare form guidati inserendo descrizioni, documentazione fotografica e Per incentivare la partecipazione, è integrato un sistema di <strong>gamification</strong>: ogni
posizione esatta (GPS). segnalazione valida e risolta fa guadagnare Punti Cittadino, premiando la cittadinanza attiva.
Per incentivare la partecipazione, è previsto il conferimento di "<strong>Punti di Cittadinanza Possono inoltre chattare con gli operatori e lasciare un feedback finale.
Attiva</strong>"
per ogni segnalazione validata e risolta dall'Ente.
</p> </p>
</div> </div>
</div> </div>
@@ -80,15 +69,13 @@ const { sortedMembers } = useMembers();
<div class="card card-bg h-100"> <div class="card card-bg h-100">
<div class="card-body"> <div class="card-body">
<BIIcon class="mb-3" color="primary" name="it-tool" size="xl"/> <BIIcon class="mb-3" color="primary" name="it-tool" size="xl"/>
<h3 class="card-title h5 font-weight-bold">Squadre Operative</h3> <h3 class="card-title h5 font-weight-bold">Operatori e Squadre</h3>
<p class="card-text text-muted"> <p class="card-text text-muted">
Gli operatori tecnici e logistici dell'Ente prendono in carico la segnalazione per l'immediata Il personale comunale visualizza una dashboard con le segnalazioni in ingresso. Possono prenderle in
istruttoria. carico, comunicare con l'utente e assegnarle alle squadre di intervento sul territorio.
<br><br> <br><br>
La piattaforma provvede a instradare automaticamente l'intervento alla squadra specializzata di Il sistema gestisce l'intero flusso di stato: da <em>Aperta</em> a <em>In Revisione</em>, fino a
turno in base alla <em>In Lavorazione</em> e <em>Risolta</em>.
categoria del problema e all'area geografica, garantendo un canale diretto per le comunicazioni di
servizio e aggiornando in tempo reale lo stato dei lavori.
</p> </p>
</div> </div>
</div> </div>
@@ -100,15 +87,14 @@ const { sortedMembers } = useMembers();
<div class="card card-bg h-100"> <div class="card card-bg h-100">
<div class="card-body"> <div class="card-body">
<BIIcon class="mb-3" color="primary" name="it-settings" size="xl"/> <BIIcon class="mb-3" color="primary" name="it-settings" size="xl"/>
<h3 class="card-title h5 font-weight-bold">L'Amministrazione</h3> <h3 class="card-title h5 font-weight-bold">Amministrazione</h3>
<p class="card-text text-muted"> <p class="card-text text-muted">
Gli amministratori dispongono del controllo completo sulle operazioni, con privilegi di gestione dei Gli amministratori hanno il controllo completo della piattaforma. Possono definire le aree urbane
profili e assegnazione delle nomine agli operatori. tramite poligoni GeoJSON, gestire i permessi degli utenti, supervisionare i feedback e moderare
eventuali abusi.
<br><br> <br><br>
L'Ente utilizza funzioni di ricerca avanzata e filtraggio per area geografica per prevenire le Hanno inoltre accesso a statistiche avanzate per individuare le criticità sul territorio e
duplicazioni monitorare i tempi medi di risoluzione.
e si avvale di strumenti analitici per il monitoraggio continuo dei tempi medi di risoluzione e
dell'efficienza dei servizi.
</p> </p>
</div> </div>
</div> </div>
@@ -121,50 +107,38 @@ const { sortedMembers } = useMembers();
<div class="container my-5 py-4"> <div class="container my-5 py-4">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-12 col-lg-7 mb-4 mb-lg-0"> <div class="col-12 col-lg-6 mb-4 mb-lg-0">
<h2 class="font-weight-bold mb-4">Sicurezza, Centralità e Conformità</h2> <h2 class="font-weight-bold mb-4">Tecnologia e Indipendenza</h2>
<p class="mb-4">Per soddisfare gli elevati standard richiesti da un Ente Pubblico, il sistema adotta precise
scelte organizzative e architetturali:</p>
<ul class="it-list"> <ul class="it-list">
<li class="d-flex align-items-start mb-3"> <li>
<BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-pa"/> <BIIcon class="me-2" color="primary" name="it-code-circle"/>
<div> <strong>Architettura:</strong> Frontend reattivo e multipiattaforma sviluppato in <em>Vue.js/Nuxt 3</em>.
<strong>Modello Centralizzato:</strong> Tutta la gestione logica e la persistenza dei dati risiedono Backend ad altissime prestazioni scritto in <em>C++</em> utilizzando il framework <em>Drogon</em>.
lato server, garantendo tracciabilità totale dei passaggi di stato e convalida rigorosa dei permessi
utente.
</div>
</li> </li>
<li class="d-flex align-items-start mb-3"> <li class="mt-3">
<BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-lock"/> <BIIcon class="me-2" color="primary" name="it-lock"/>
<div> <strong>Sicurezza:</strong> Accesso con OTP e sistema di autorizzazione Role-Based basato su token JWT.
<strong>Sicurezza e Privacy:</strong> Tutte le comunicazioni sono interamente cifrate (tramite rigidi
protocolli crittografici) per garantire la massima conformità alle normative sulla protezione dei dati
personali.
</div>
</li> </li>
<li class="d-flex align-items-start mb-3"> <li class="mt-3">
<BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-mail"/> <BIIcon class="me-2" color="primary" name="it-piu"/>
<div> <strong>Open Data:</strong> Piena aderenza alle linee guida AGID sulla trasparenza, con esportazione di
<strong>Accesso Operatori:</strong> Il personale tecnico comunale accede al sistema tramite un approccio dati anonimizzati accessibili al pubblico.
sicuro basato su One-Time Password (OTP) inviata sull'e-mail aziendale.
</div>
</li> </li>
</ul> </ul>
</div> </div>
<div class="col-12 col-lg-5 offset-lg-1">
<div class="col-12 col-lg-4 offset-lg-1"> <div class="callout callout-highlight callout-warning">
<div class="callout callout-highlight callout-note">
<div class="callout-title"> <div class="callout-title">
<BIIcon name="it-info-circle"/> <BIIcon name="it-warning-circle"/>
Customer Satisfaction Integrata Un approccio etico al codice sorgente
</div> </div>
<p> <p>
La piattaforma non si esaurisce con l'intervento tecnico, ma chiude il ciclo con un modulo dedicato alla In risposta alle policy sempre più aggressive delle grandi piattaforme commerciali riguardo
<strong>Valutazione della Qualità</strong>. l'addestramento non consensuale di modelli di Intelligenza Artificiale sui repository privati, lo sviluppo
di questo progetto è ospitato su un'istanza <strong>self-hosted di Gitea</strong>.
<br><br> <br><br>
A risoluzione avvenuta, il cittadino viene invitato a esprimere un riscontro sulla tempestività e Una scelta di principio per garantire il controllo totale, la privacy e la vera sovranità sui dati e sul
sull'efficacia codice.
del lavoro svolto, assicurando all'amministrazione un controllo qualità costante.
</p> </p>
</div> </div>
</div> </div>
@@ -176,34 +150,26 @@ const { sortedMembers } = useMembers();
<div class="card card-bg border-start border-primary border-5"> <div class="card card-bg border-start border-primary border-5">
<div class="card-body"> <div class="card-body">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-12 col-md-6 mb-4 mb-md-0"> <div class="col-12 col-md-8">
<h4 class="h5 font-weight-bold text-primary mb-2">Il Team di Sviluppo</h4> <h4 class="h5 font-weight-bold text-primary mb-2">Crediti Accademici</h4>
<p class="mb-1"> <p class="mb-1">
Questo portale è stato ideato e sviluppato per facilitare il percorso di transizione digitale Questo progetto è stato realizzato nell'ambito del corso di <strong>Laboratorio di
e modernizzare l'infrastruttura telematica a disposizione della cittadinanza. Programmazione</strong> (tenuto dal Prof. Vessio).
</p>
<p class="text-muted small mb-0">
Università degli Studi di Bari "Aldo Moro" - Dipartimento di Informatica.
</p> </p>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-4 mt-3 mt-md-0 text-md-end">
<div class="d-flex flex-wrap justify-content-md-end justify-content-center gap-4"> <p class="font-weight-bold mb-1">Sviluppato da:</p>
<div <ul class="list-unstyled mb-0">
v-for="member in sortedMembers" <li>Renato Loiacono</li>
:key="member.name" <li>Michelangelo Parisi</li>
class="d-flex flex-column align-items-center text-center" <li>Gabriele Schiraldi</li>
style="max-width: 90px;" </ul>
>
<img
:alt="member.name"
:src="member.image"
class="rounded-circle mb-2 shadow-sm"
style="width: 72px; height: 72px; object-fit: cover; border: 2px solid #0066cc;"
>
<span class="font-weight-bold small" style="line-height: 1.2;">{{ member.name }}</span>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -216,8 +182,4 @@ const { sortedMembers } = useMembers();
list-style-type: none; list-style-type: none;
padding-left: 0; padding-left: 0;
} }
h2 {
text-align: center;
}
</style> </style>
-117
View File
@@ -1,117 +0,0 @@
<script setup lang="ts">
definePageMeta({
layout: 'default'
})
useHead({ title: 'Intervento pubblico - Visione' })
const route = useRoute()
const runtimeConfig = useRuntimeConfig()
const token = computed(() => String(route.params.token || ''))
const { getPublicIntervention } = useOperator()
const { data, pending, error } = await useAsyncData(
`public-intervention-${token.value}`,
() => getPublicIntervention(token.value),
)
const payload = computed<any>(() => data.value || null)
const report = computed<any>(() => payload.value?.report || payload.value?.summary || payload.value?.intervention || payload.value)
const media = computed<any[]>(() => payload.value?.media || [])
const messages = computed<any[]>(() => payload.value?.latest_messages || payload.value?.messages || [])
const formatDate = (dateString?: string) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('it-IT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<template>
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-8">
<div v-if="pending" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
<div v-else-if="error || !report" class="card card-bg border-0 shadow-sm">
<div class="card-body py-5 text-center">
<BIIcon name="it-close-circle" size="lg" color="danger" class="mb-3" />
<h1 class="h4 mb-2">Link non disponibile</h1>
<p class="text-muted mb-0">Questo link di intervento non è valido, è scaduto oppure è stato revocato.</p>
</div>
</div>
<template v-else>
<div class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h1 class="h4 mb-2">Intervento pubblico</h1>
<p class="text-muted mb-4">Scheda condivisa per team esterno.</p>
<div class="row g-3">
<div class="col-12 col-md-6">
<small class="text-muted d-block">Segnalazione</small>
<strong>#{{ report.id || '-' }}</strong>
</div>
<div class="col-12 col-md-6">
<small class="text-muted d-block">Categoria</small>
<strong>{{ report.category_name || '-' }}</strong>
</div>
<div class="col-12">
<small class="text-muted d-block">Indirizzo</small>
<strong>{{ report.address || '-' }}</strong>
</div>
<div class="col-12">
<small class="text-muted d-block">Descrizione</small>
<p class="mb-0">{{ report.description || '-' }}</p>
</div>
<div class="col-12 col-md-6">
<small class="text-muted d-block">Stato</small>
<strong>{{ report.status || '-' }}</strong>
</div>
<div class="col-12 col-md-6">
<small class="text-muted d-block">Aperta</small>
<strong>{{ formatDate(report.created_at) }}</strong>
</div>
</div>
</div>
</div>
<div v-if="media.length > 0" class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h6 mb-3">Allegati</h2>
<div class="row g-2">
<div v-for="item in media" :key="item.id" class="col-6 col-md-4">
<img
v-if="item.media_kind === 'IMAGE'"
:src="runtimeConfig.public.api + item.url"
class="img-fluid rounded border"
:alt="item.original_filename || 'Allegato'"
/>
</div>
</div>
</div>
</div>
<div v-if="messages.length > 0" class="card card-bg border-0 shadow-sm">
<div class="card-body">
<h2 class="h6 mb-3">Ultimi aggiornamenti</h2>
<ul class="list-unstyled mb-0">
<li v-for="message in messages" :key="message.id" class="border-bottom py-2">
<div class="small text-muted mb-1">{{ formatDate(message.created_at) }}</div>
<div>{{ message.body }}</div>
</li>
</ul>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
+61 -53
View File
@@ -1,13 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Notification } from '~/types/api'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
}) })
useHead({ title: 'Notifiche - Visione' }) useHead({ title: 'Notifiche - Visione' })
const { fetchNotifications, markNotificationRead, refreshUnreadNotifications } = useAuth() const { fetchNotifications, markNotificationRead } = useAuth()
const router = useRouter() const router = useRouter()
const page = ref(1) const page = ref(1)
@@ -15,25 +13,27 @@ const perPage = ref(15)
const { data: notificationsData, refresh: refreshNotifications } = await useAsyncData( const { data: notificationsData, refresh: refreshNotifications } = await useAsyncData(
'notifications', 'notifications',
() => fetchNotifications({ page: page.value, per_page: perPage.value }), () => fetchNotifications({ page: page.value, per_page: perPage.value })
{ watch: [page] }
) )
const notifications = computed<Notification[]>(() => notificationsData.value?.notifications || []) const notifications = computed(() => notificationsData.value?.notifications || [])
const totalNotifications = computed(() => notificationsData.value?.notifications?.length || 0) const totalNotifications = computed(() => notificationsData.value?.notifications?.length || 0)
const unreadCount = computed(() => notifications.value.filter((notification) => !notification.read_at).length) const unreadCount = computed(() => notifications.value.filter((n: any) => !n.read_at).length)
const handlePageChange = (newPage: number) => {
page.value = newPage
}
const handleMarkAsRead = async (notificationId: number) => { const handleMarkAsRead = async (notificationId: number) => {
try { try {
await markNotificationRead(notificationId) await markNotificationRead(notificationId)
await refreshNotifications() await refreshNotifications()
await refreshUnreadNotifications()
} catch (error) { } catch (error) {
console.error('Error marking notification as read:', error) console.error('Error marking notification as read:', error)
} }
} }
const handleNotificationClick = async (notification: Notification) => { const handleNotificationClick = async (notification: any) => {
if (!notification.read_at) { if (!notification.read_at) {
await handleMarkAsRead(notification.id) await handleMarkAsRead(notification.id)
} }
@@ -111,62 +111,65 @@ const getNotificationColor = (type: string): string => {
<div class="container mb-5"> <div class="container mb-5">
<div class="row"> <div class="row">
<div class="col-12 col-lg-10 offset-lg-1"> <div class="col-12 col-lg-10 offset-lg-1">
<div v-if="notifications.length === 0" class="card card-bg border-0 shadow-sm">
<div class="card-body py-5 text-center"> <div class="d-flex gap-2 mb-4">
<BIIcon name="it-bell" size="lg" color="secondary" class="mb-3 d-block mx-auto" /> <button class="btn btn-sm btn-outline-primary active">Tutti</button>
<h2 class="h5 mb-2">Nessuna notifica</h2> <button class="btn btn-sm btn-outline-secondary">Solo non letti</button>
<p class="text-muted mb-3">Quando arriveranno aggiornamenti sulle tue segnalazioni, li troverai qui.</p>
<NuxtLink to="/reports" class="btn btn-outline-primary btn-sm">
Vai alle segnalazioni
</NuxtLink>
</div>
</div> </div>
<div v-else class="card card-bg border-0 shadow-sm"> <div v-if="notifications.length === 0" class="text-center py-5">
<div class="card-body p-0"> <BIIcon name="it-bell" size="xl" color="info" class="mb-3 d-block" />
<div <h6 class="text-muted">Nessuna notifica</h6>
<p class="small text-muted">Quando riceverai nuovi aggiornamenti sulle tue segnalazioni, appariranno qui.</p>
</div>
<div v-else class="list-group">
<button
v-for="notification in notifications" v-for="notification in notifications"
:key="notification.id" :key="notification.id"
class="notification-row p-3" type="button"
:class="{ 'notification-row-unread': !notification.read_at }" class="list-group-item list-group-item-action border-0 p-3 mb-3 rounded-2"
:class="{
'bg-light': !notification.read_at,
'bg-white': notification.read_at,
}"
@click="handleNotificationClick(notification)"
> >
<div class="d-flex align-items-start gap-3"> <div class="d-flex align-items-start gap-3">
<div class="flex-shrink-0 mt-1"> <div class="flex-shrink-0 mt-1">
<div <div class="rounded-circle p-2" :class="`bg-${getNotificationColor(notification.type)}`" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;">
class="rounded-circle d-flex align-items-center justify-content-center notification-icon"
:class="`bg-${getNotificationColor(notification.type)}`"
>
<BIIcon :name="getNotificationIcon(notification.type)" size="sm" color="white" /> <BIIcon :name="getNotificationIcon(notification.type)" size="sm" color="white" />
</div> </div>
</div> </div>
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="d-flex flex-wrap justify-content-between gap-2"> <div class="d-flex align-items-start justify-content-between">
<div> <div>
<h2 class="h6 mb-1">{{ notification.title }}</h2> <h6 class="mb-1 font-weight-bold" :class="{ 'text-dark': !notification.read_at }">
<p class="mb-1 text-muted">{{ notification.body }}</p> {{ notification.title }}
<small class="text-muted">{{ formatDate(notification.created_at) }}</small> </h6>
<p class="mb-1 small" :class="{ 'text-muted': notification.read_at }">
{{ notification.body }}
</p>
<small class="text-muted">
{{ formatDate(notification.created_at) }}
</small>
</div> </div>
<div v-if="!notification.read_at" class="flex-shrink-0"> <div v-if="!notification.read_at" class="flex-shrink-0">
<span class="badge bg-primary">Nuovo</span> <span class="badge bg-primary rounded-pill">Nuovo</span>
</div> </div>
</div> </div>
<div class="mt-3 d-flex flex-wrap gap-2"> <div v-if="notification.report_id" class="mt-2 d-flex gap-2">
<button <NuxtLink :to="`/reports/${notification.report_id}`" class="btn btn-sm btn-outline-primary">
type="button" <BIIcon name="it-arrow-right" size="xs" class="me-1" />
class="btn btn-outline-primary btn-sm"
@click="handleNotificationClick(notification)"
>
Apri notifica
</button>
<NuxtLink v-if="notification.report_id" :to="`/reports/${notification.report_id}`" class="btn btn-outline-primary btn-sm">
Vedi segnalazione Vedi segnalazione
</NuxtLink> </NuxtLink>
<button <button
v-if="!notification.read_at" v-if="!notification.read_at"
type="button" type="button"
class="btn btn-outline-secondary btn-sm" class="btn btn-sm btn-outline-secondary"
@click.stop="handleMarkAsRead(notification.id)" @click.stop="handleMarkAsRead(notification.id)"
> >
Segna come letta Segna come letta
@@ -174,8 +177,7 @@ const getNotificationColor = (type: string): string => {
</div> </div>
</div> </div>
</div> </div>
</div> </button>
</div>
</div> </div>
<Pagination <Pagination
@@ -183,7 +185,7 @@ const getNotificationColor = (type: string): string => {
:current-page="page" :current-page="page"
:per-page="perPage" :per-page="perPage"
:total-items="totalNotifications" :total-items="totalNotifications"
@page-change="page = $event" @page-change="handlePageChange"
/> />
</div> </div>
@@ -194,17 +196,23 @@ const getNotificationColor = (type: string): string => {
</template> </template>
<style scoped> <style scoped>
.notification-row + .notification-row { .list-group-item {
border-top: 1px solid #e9ecef; border: 1px solid #e9ecef;
transition: all 0.2s ease;
cursor: pointer;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
} }
.notification-row-unread { &.bg-light {
border-left: 4px solid #0066cc; border-left: 4px solid #006f9d;
background: #f8fbff; }
} }
.notification-icon { .rounded-2 {
width: 40px; border-radius: 0.5rem;
height: 40px;
} }
</style> </style>
+40 -28
View File
@@ -7,27 +7,27 @@ const { getOpenDataReports } = usePublic()
const currentPage = ref(1) const currentPage = ref(1)
const perPage = ref(9) const perPage = ref(9)
const { data: openDataResponse, pending } = await useAsyncData( const { data: openDataResponse, pending } = await useAsyncData(
'open-data-reports', 'open-data-reports',
() => getOpenDataReports({ page: 1, per_page: 200 }) () => getOpenDataReports({ page: currentPage.value, per_page: perPage.value }),
{ watch: [currentPage] }
) )
const allVisibleReports = computed(() => const visibleReports = computed(() =>
(openDataResponse.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude)) (openDataResponse.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude))
) )
const visibleReports = computed(() => { const isLastPage = computed(() => {
const start = (currentPage.value - 1) * perPage.value if (!openDataResponse.value?.reports) return true
const end = start + perPage.value return openDataResponse.value.reports.length < perPage.value
return allVisibleReports.value.slice(start, end)
}) })
const handlePageChange = (page: number) => { const nextPage = () => {
currentPage.value = page if (!isLastPage.value) currentPage.value++
}
const prevPage = () => {
if (currentPage.value > 1) currentPage.value--
} }
const mapVisibleReports = computed(() => allVisibleReports.value)
const currentView = ref<'grid' | 'map'>('grid') const currentView = ref<'grid' | 'map'>('grid')
const mapContainer = ref<HTMLElement | null>(null) const mapContainer = ref<HTMLElement | null>(null)
@@ -66,12 +66,12 @@ const renderMapMarkers = async () => {
clearMarkers() clearMarkers()
if (mapVisibleReports.value.length === 0) { if (visibleReports.value.length === 0) {
map.setView(BARI_CENTER, 12) map.setView(BARI_CENTER, 12)
return return
} }
mapVisibleReports.value.forEach((report) => { visibleReports.value.forEach((report) => {
const marker = L.circleMarker([report.latitude, report.longitude], { const marker = L.circleMarker([report.latitude, report.longitude], {
radius: 8, radius: 8,
weight: 2, weight: 2,
@@ -93,7 +93,7 @@ const renderMapMarkers = async () => {
markers.push(marker) markers.push(marker)
}) })
const fitBounds: LatLngBoundsExpression = mapVisibleReports.value.map((report) => [report.latitude, report.longitude]) const fitBounds: LatLngBoundsExpression = visibleReports.value.map((report) => [report.latitude, report.longitude])
map.fitBounds(fitBounds, { padding: [32, 32], maxZoom: 15 }) map.fitBounds(fitBounds, { padding: [32, 32], maxZoom: 15 })
} }
@@ -122,6 +122,7 @@ const initMap = async () => {
watch(currentView, async (view) => { watch(currentView, async (view) => {
if (view !== 'map') return if (view !== 'map') return
await nextTick() await nextTick()
// Inizializza la mappa se non esiste, altrimenti aggiornane solo dimensioni e dati
if (!map) { if (!map) {
await initMap() await initMap()
} else { } else {
@@ -130,7 +131,7 @@ watch(currentView, async (view) => {
} }
}) })
watch(mapVisibleReports, async () => { watch(visibleReports, async () => {
if (currentView.value === 'map' && map) { if (currentView.value === 'map' && map) {
await renderMapMarkers() await renderMapMarkers()
} }
@@ -160,7 +161,7 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<div class="container my-5" id="grid-start"> <div class="container my-5">
<div class="row align-items-center mb-5 pb-3 border-bottom"> <div class="row align-items-center mb-5 pb-3 border-bottom">
<div class="col-12 col-md-6 mb-3 mb-md-0 d-flex gap-2"> <div class="col-12 col-md-6 mb-3 mb-md-0 d-flex gap-2">
<button <button
@@ -188,6 +189,7 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<!-- Sostituito v-else-if con v-show -->
<div v-if="pending" class="d-flex justify-content-center my-5 py-5"> <div v-if="pending" class="d-flex justify-content-center my-5 py-5">
<div class="progress-spinner progress-spinner-active" role="status"> <div class="progress-spinner progress-spinner-active" role="status">
<span class="visually-hidden">Caricamento in corso...</span> <span class="visually-hidden">Caricamento in corso...</span>
@@ -195,7 +197,7 @@ onBeforeUnmount(() => {
</div> </div>
<div v-show="!pending && currentView === 'grid'"> <div v-show="!pending && currentView === 'grid'">
<div class="row" v-if="allVisibleReports.length > 0"> <div class="row" v-if="openDataResponse?.reports && openDataResponse.reports.length > 0">
<div class="col-12 col-md-6 col-lg-4 mb-4" v-for="report in visibleReports" :key="report.id"> <div class="col-12 col-md-6 col-lg-4 mb-4" v-for="report in visibleReports" :key="report.id">
<div class="card-wrapper card-space h-100"> <div class="card-wrapper card-space h-100">
<div class="card card-bg h-100 shadow-sm"> <div class="card card-bg h-100 shadow-sm">
@@ -209,6 +211,7 @@ onBeforeUnmount(() => {
{{ getStatusBadge(report.status).text }} {{ getStatusBadge(report.status).text }}
</span> </span>
</div> </div>
<span class="text-muted small">ID: #{{ report.id }}</span>
</div> </div>
<h3 class="card-title h5 font-weight-bold">{{ report.category_name }}</h3> <h3 class="card-title h5 font-weight-bold">{{ report.category_name }}</h3>
@@ -241,22 +244,31 @@ onBeforeUnmount(() => {
Nessun dato pubblico disponibile al momento. Nessun dato pubblico disponibile al momento.
</div> </div>
<Pagination <nav class="pagination-wrapper justify-content-center mt-5" aria-label="Navigazione pagine open data">
v-if="allVisibleReports.length > 0" <ul class="pagination">
:current-page="currentPage" <li class="page-item" :class="{ disabled: currentPage === 1 }">
:per-page="perPage" <a class="page-link" href="#" tabindex="-1" aria-disabled="true" @click.prevent="prevPage">
:total-items="allVisibleReports.length" <BIIcon name="it-chevron-left" size="sm"/>
@page-change="handlePageChange" <span class="visually-hidden">Pagina precedente</span>
/> </a>
</li>
<li class="page-item">
<span class="page-link font-weight-semibold">Pagina {{ currentPage }}</span>
</li>
<li class="page-item" :class="{ disabled: isLastPage }">
<a class="page-link" href="#" @click.prevent="nextPage">
<span class="visually-hidden">Pagina successiva</span>
<BIIcon name="it-chevron-right" size="sm"/>
</a>
</li>
</ul>
</nav>
</div> </div>
<div v-show="!pending && currentView === 'map'" class="map-view-wrapper"> <div v-show="!pending && currentView === 'map'" class="map-view-wrapper">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<p class="text-muted mb-0">Mappa pubblica delle segnalazioni nell'area di Bari.</p> <p class="text-muted mb-0">Mappa pubblica delle segnalazioni nell'area di Bari.</p>
<span class="badge bg-light text-dark border"> <span class="badge bg-light text-dark border">{{ visibleReports.length }} punti</span>
<template v-if="pending">Caricamento...</template>
<template v-else>{{ mapVisibleReports.length }} punti</template>
</span>
</div> </div>
<ClientOnly> <ClientOnly>
<div ref="mapContainer" class="open-data-map rounded border"></div> <div ref="mapContainer" class="open-data-map rounded border"></div>
+1 -1
View File
@@ -25,7 +25,7 @@ const { data: reportsRes, pending } = useAsyncData(
<h1 class="h3 font-weight-bold mb-0">Le Mie Assegnazioni</h1> <h1 class="h3 font-weight-bold mb-0">Le Mie Assegnazioni</h1>
<div> <div>
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink> <NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink>
<NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Segnalazioni disponibili</NuxtLink> <NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Coda Open</NuxtLink>
</div> </div>
</div> </div>
+18 -5
View File
@@ -6,22 +6,34 @@ definePageMeta({
useHead({ title: 'Segnalazioni Aperte - Operatore' }) useHead({ title: 'Segnalazioni Aperte - Operatore' })
const { getOpenReports } = useOperator() const { getOpenReports, takeReport } = useOperator()
const page = ref(1) const page = ref(1)
const perPage = ref(10) const perPage = ref(10)
const { data: reportsRes, pending } = useAsyncData( const { data: reportsRes, pending, refresh } = useAsyncData(
'operator-open-reports', 'operator-open-reports',
() => getOpenReports({ page: page.value, per_page: perPage.value }), () => getOpenReports({ page: page.value, per_page: perPage.value }),
{ watch: [page] } { watch: [page] }
) )
const handleTakeReport = async (id: number) => {
try {
const res = await takeReport(id)
if (res.ok) {
await refresh()
}
} catch (err) {
console.error('Failed to take report', err)
}
}
</script> </script>
<template> <template>
<div class="container my-5"> <div class="container my-5">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 font-weight-bold mb-0">Segnalazioni da valutare</h1> <h1 class="h3 font-weight-bold mb-0">Segnalazioni da prendere in carico (OPEN)</h1>
<div> <div>
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink> <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> <NuxtLink to="/operator/assigned" class="btn btn-outline-primary btn-sm">Le mie assegnazioni</NuxtLink>
@@ -33,14 +45,15 @@ const { data: reportsRes, pending } = useAsyncData(
</div> </div>
<div v-else-if="!reportsRes?.reports || reportsRes.reports.length === 0" class="alert alert-info"> <div v-else-if="!reportsRes?.reports || reportsRes.reports.length === 0" class="alert alert-info">
Non ci sono segnalazioni disponibili in questo momento. Non ci sono segnalazioni aperte in questo momento.
</div> </div>
<div v-else> <div v-else>
<ReportTable <ReportTable
:reports="reportsRes.reports" :reports="reportsRes.reports"
action-variant="manage"
detail-base-path="/operator/reports" detail-base-path="/operator/reports"
action-label="Visualizza" @take="handleTakeReport"
/> />
<Pagination <Pagination
+1 -1
View File
@@ -25,7 +25,7 @@ const { data: reportsRes, pending } = useAsyncData(
<h1 class="h3 font-weight-bold mb-0">Gestite da altri Operatori</h1> <h1 class="h3 font-weight-bold mb-0">Gestite da altri Operatori</h1>
<div> <div>
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink> <NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink>
<NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Segnalazioni disponibili</NuxtLink> <NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Coda Open</NuxtLink>
</div> </div>
</div> </div>
+44 -289
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FlagRequest, Message, Report, ReportStatus } from '~/types/api' import type { Message, ReportStatus } from '~/types/api'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
@@ -16,49 +16,22 @@ if (!reportId) {
navigateTo('/operator') navigateTo('/operator')
} }
const { getReportDetail, getMessages, addMessage, flagMessage, flagReport, getTeamSuggestions } = useReports() const { getReportDetail, getMessages, addMessage, flagMessage } = useReports()
const { const { getInterventionSummary, updateReportStatus, assignTeam, getTeams, createInterventionLink, revokeInterventionLink } = useOperator()
takeReport,
getInterventionSummary,
updateReportStatus,
assignTeam,
getTeams,
createInterventionLink,
revokeInterventionLink
} = useOperator()
const { user } = useAuth() const { user } = useAuth()
const { data: reportData, refresh: refreshReport } = await useAsyncData(`op-report-${reportId}`, () => getReportDetail(reportId)) 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: messagesData, refresh: refreshMessages } = await useAsyncData(`op-messages-${reportId}`, () => getMessages(reportId))
const { data: summaryData, refresh: refreshSummary } = await useAsyncData(`op-summary-${reportId}`, () => getInterventionSummary(reportId)) const { data: summaryData, refresh: refreshSummary } = await useAsyncData(`op-summary-${reportId}`, () => getInterventionSummary(reportId))
const { data: teamsData } = await useAsyncData('operator-teams', () => getTeams())
const { data: allTeamsData } = await useAsyncData('operator-teams', () => getTeams()) const report = computed(() => (reportData.value as any)?.report)
const { data: suggestedTeamsData } = await useAsyncData(`op-team-suggestions-${reportId}`, () => getTeamSuggestions(reportId))
const report = computed<Report | undefined>(() => (reportData.value as any)?.report)
const media = computed(() => (reportData.value as any)?.media || []) const media = computed(() => (reportData.value as any)?.media || [])
const statusHistory = computed(() => (reportData.value as any)?.history || []) const statusHistory = computed(() => (reportData.value as any)?.history || [])
const messages = computed<Message[]>(() => (messagesData.value as any)?.messages || []) const messages = computed(() => (messagesData.value as any)?.messages || [])
const summary = computed(() => (summaryData.value as any)?.summary) const summary = computed(() => (summaryData.value as any)?.summary)
const summaryMedia = computed(() => (summaryData.value as any)?.media || []) const summaryMedia = computed(() => (summaryData.value as any)?.media || [])
const teams = computed(() => teamsData.value?.teams || [])
const allTeams = computed(() => (allTeamsData.value as any)?.teams || [])
const suggestedTeams = computed(() => (suggestedTeamsData.value as any)?.suggestions || [])
const otherTeams = computed(() => {
const suggestedIds = new Set(suggestedTeams.value.map((t: any) => Number(t.id)))
return allTeams.value.filter((t: any) => !suggestedIds.has(Number(t.id)))
})
const currentUserId = computed(() => user.value?.id ?? null)
const isTakenByCurrentOperator = computed(() => report.value?.assigned_operator_id === currentUserId.value)
const isOpenAndUnassigned = computed(() => report.value?.status === 'OPEN' && !report.value?.assigned_operator_id)
const isReadOnlyPreview = computed(() => !isTakenByCurrentOperator.value)
const canTakeReport = computed(() => isOpenAndUnassigned.value && !isTakenByCurrentOperator.value)
const isFinalStatus = computed(() => ['REJECTED', 'RESOLVED', 'CLOSED'].includes(report.value?.status || ''))
const canAssignTeam = computed(() => !isReadOnlyPreview.value && !isFinalStatus.value && allTeams.value.length > 0)
const canSendMessage = computed(() => !isReadOnlyPreview.value && !isFinalStatus.value)
const canChangeStatus = computed(() => !isReadOnlyPreview.value && !isFinalStatus.value && selectableStatuses.value.length > 0)
const statusMeta: Record<string, string> = { const statusMeta: Record<string, string> = {
OPEN: 'Aperta', OPEN: 'Aperta',
@@ -67,7 +40,7 @@ const statusMeta: Record<string, string> = {
IN_PROGRESS: 'In lavorazione', IN_PROGRESS: 'In lavorazione',
RESOLVED: 'Risolta', RESOLVED: 'Risolta',
REJECTED: 'Respinta', REJECTED: 'Respinta',
CLOSED: 'Conclusa' CLOSED: 'Chiusa'
} }
const statusTransitions: Record<string, string[]> = { const statusTransitions: Record<string, string[]> = {
@@ -87,7 +60,7 @@ const availableStatuses: { value: ReportStatus, label: string }[] = [
{ value: 'IN_PROGRESS', label: 'In Lavorazione' }, { value: 'IN_PROGRESS', label: 'In Lavorazione' },
{ value: 'RESOLVED', label: 'Risolta' }, { value: 'RESOLVED', label: 'Risolta' },
{ value: 'REJECTED', label: 'Rifiutata' }, { value: 'REJECTED', label: 'Rifiutata' },
{ value: 'CLOSED', label: 'Conclusa' } { value: 'CLOSED', label: 'Chiusa' }
] ]
const selectableStatuses = computed(() => { const selectableStatuses = computed(() => {
@@ -97,32 +70,26 @@ const selectableStatuses = computed(() => {
}) })
const canCreateInterventionLink = computed(() => { const canCreateInterventionLink = computed(() => {
if (isReadOnlyPreview.value) return false
if (summary.value?.assignments && Array.isArray(summary.value.assignments)) { if (summary.value?.assignments && Array.isArray(summary.value.assignments)) {
return summary.value.assignments.some((a: any) => !a.ended_at) return summary.value.assignments.some((a: any) => !a.ended_at)
} }
return ['ASSIGNED', 'IN_PROGRESS'].includes(report.value?.status || '') return ['ASSIGNED', 'IN_PROGRESS'].includes(report.value?.status)
}) })
const newStatus = ref<ReportStatus>('IN_PROGRESS') const newStatus = ref<ReportStatus>('IN_PROGRESS')
const statusNote = ref('') const statusNote = ref('')
const isUpdatingStatus = ref(false) const isUpdatingStatus = ref(false)
const isTakingReport = ref(false)
const isAssignModalOpen = ref(false) const isAssignModalOpen = ref(false)
const selectedTeamId = ref<number | null>(null) const selectedTeamId = ref<number | null>(null)
const isAssigning = ref(false) const isAssigning = ref(false)
const interventionLink = ref('') const interventionLink = ref('')
const interventionLinkExpiresAt = ref('') const interventionLinkExpiresAt = ref('')
const interventionPublicLink = ref('')
const isGeneratingInterventionLink = ref(false) const isGeneratingInterventionLink = ref(false)
const isRevokingInterventionLink = ref(false) const isRevokingInterventionLink = ref(false)
const interventionLinkError = ref('') const interventionLinkError = ref('')
const interventionLinkSuccess = ref('') const interventionLinkSuccess = ref('')
const isRevokeInterventionModalOpen = ref(false) const isRevokeInterventionModalOpen = ref(false)
const copyFeedback = ref('')
const actionError = ref('')
const actionSuccess = ref('')
const isSubmittingMessage = ref(false) const isSubmittingMessage = ref(false)
const flaggedMessageIds = ref<number[]>([]) const flaggedMessageIds = ref<number[]>([])
@@ -131,35 +98,6 @@ const messageToFlag = ref<Message | null>(null)
const isFlaggingMessage = ref(false) const isFlaggingMessage = ref(false)
const isFlaggingMessageId = computed(() => isFlaggingMessage.value ? messageToFlag.value?.id : null) const isFlaggingMessageId = computed(() => isFlaggingMessage.value ? messageToFlag.value?.id : null)
const isFlagReportModalOpen = ref(false)
const isFlaggingReport = ref(false)
const reportFlagError = ref('')
const reportFlagSuccess = ref('')
const reportFlagForm = reactive<FlagRequest>({
reason: '',
details: ''
})
// === GESTIONE SCROLL SCHERMO CON MODALI ===
watch(
() => isAssignModalOpen.value || isFlagReportModalOpen.value || isRevokeInterventionModalOpen.value || isFlagMessageModalOpen.value,
(isOpen) => {
if (import.meta.client) {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
}
}
)
onUnmounted(() => {
if (import.meta.client) {
document.body.style.overflow = ''
}
})
watch(() => report.value?.status, (val) => { watch(() => report.value?.status, (val) => {
if (val) { if (val) {
const allowed = statusTransitions[val] || [] const allowed = statusTransitions[val] || []
@@ -169,54 +107,8 @@ watch(() => report.value?.status, (val) => {
} }
}, { immediate: true }) }, { immediate: true })
const resetReportFlagForm = () => {
reportFlagForm.reason = ''
reportFlagForm.details = ''
reportFlagError.value = ''
}
const clearActionFeedback = () => {
actionError.value = ''
actionSuccess.value = ''
}
const setPublicInterventionLink = (url?: string) => {
if (!url) {
interventionPublicLink.value = ''
return
}
try {
const rawUrl = new URL(url, runtimeConfig.public.api)
interventionPublicLink.value = `${runtimeConfig.public.url}${rawUrl.pathname.replace(/^\/api\/v1/, '')}`
} catch {
interventionPublicLink.value = url
}
}
const handleTakeReport = async () => {
if (!canTakeReport.value || !report.value) return
isTakingReport.value = true
clearActionFeedback()
try {
const res = await takeReport(report.value.id)
if (res.ok) {
await Promise.all([refreshReport(), refreshSummary()])
actionSuccess.value = 'Segnalazione presa in carico.'
}
} catch (e: any) {
console.error('Failed to take report', e)
actionError.value = e?.data?.message || 'Presa in carico non riuscita.'
} finally {
isTakingReport.value = false
}
}
const handleStatusUpdate = async () => { const handleStatusUpdate = async () => {
if (!canChangeStatus.value) return
isUpdatingStatus.value = true isUpdatingStatus.value = true
clearActionFeedback()
try { try {
const res = await updateReportStatus(reportId, { const res = await updateReportStatus(reportId, {
status: newStatus.value, status: newStatus.value,
@@ -225,22 +117,19 @@ const handleStatusUpdate = async () => {
if (res.ok) { if (res.ok) {
statusNote.value = '' statusNote.value = ''
await Promise.all([refreshReport(), refreshSummary()]) await Promise.all([refreshReport(), refreshSummary()])
actionSuccess.value = 'Stato aggiornato con successo.'
} }
} catch (e: any) { } catch (e) {
console.error('Failed to update status', e) console.error('Failed to update status', e)
actionError.value = e?.data?.message || 'Aggiornamento stato non riuscito.'
} finally { } finally {
isUpdatingStatus.value = false isUpdatingStatus.value = false
} }
} }
const handleAssignTeam = async () => { const handleAssignTeam = async () => {
if (!canAssignTeam.value) return if (isAdmin(user.value?.role)) return
if (!selectedTeamId.value) return if (!selectedTeamId.value) return
isAssigning.value = true isAssigning.value = true
clearActionFeedback()
try { try {
const res = await assignTeam(reportId, { const res = await assignTeam(reportId, {
team_id: selectedTeamId.value team_id: selectedTeamId.value
@@ -249,38 +138,30 @@ const handleAssignTeam = async () => {
isAssignModalOpen.value = false isAssignModalOpen.value = false
selectedTeamId.value = null selectedTeamId.value = null
await Promise.all([refreshReport(), refreshSummary()]) await Promise.all([refreshReport(), refreshSummary()])
actionSuccess.value = 'Team assegnato con successo.'
} }
} catch (e: any) { } catch (e) {
console.error('Failed to assign team', e) console.error('Failed to assign team', e)
actionError.value = e?.data?.message || 'Assegnazione team non riuscita.'
} finally { } finally {
isAssigning.value = false isAssigning.value = false
} }
} }
const copyInterventionLink = async () => { const copyInterventionLink = async () => {
if (!interventionPublicLink.value || !navigator?.clipboard) return if (!interventionLink.value || !navigator?.clipboard) return
try { try {
await navigator.clipboard.writeText(interventionPublicLink.value) await navigator.clipboard.writeText(interventionLink.value)
copyFeedback.value = 'Copiato'
interventionLinkSuccess.value = 'Link copiato negli appunti.' interventionLinkSuccess.value = 'Link copiato negli appunti.'
interventionLinkError.value = '' interventionLinkError.value = ''
setTimeout(() => {
copyFeedback.value = ''
}, 2000)
} catch { } catch {
interventionLinkError.value = 'Copia automatica non riuscita.' interventionLinkError.value = 'Copia automatica non riuscita.'
interventionLinkSuccess.value = '' interventionLinkSuccess.value = ''
copyFeedback.value = ''
} }
} }
const handleCreateInterventionLink = async () => { const handleCreateInterventionLink = async () => {
if (!canCreateInterventionLink.value) return if (!canCreateInterventionLink.value) return
isGeneratingInterventionLink.value = true isGeneratingInterventionLink.value = true
clearActionFeedback()
interventionLinkError.value = '' interventionLinkError.value = ''
interventionLinkSuccess.value = '' interventionLinkSuccess.value = ''
@@ -289,7 +170,6 @@ const handleCreateInterventionLink = async () => {
if (res.ok) { if (res.ok) {
interventionLink.value = res.url interventionLink.value = res.url
interventionLinkExpiresAt.value = res.expires_at interventionLinkExpiresAt.value = res.expires_at
setPublicInterventionLink(res.url)
interventionLinkSuccess.value = 'Link intervento generato.' interventionLinkSuccess.value = 'Link intervento generato.'
} }
} catch (e: any) { } catch (e: any) {
@@ -314,7 +194,6 @@ const handleRevokeInterventionLink = async () => {
if (!interventionLink.value) return if (!interventionLink.value) return
isRevokingInterventionLink.value = true isRevokingInterventionLink.value = true
clearActionFeedback()
interventionLinkError.value = '' interventionLinkError.value = ''
interventionLinkSuccess.value = '' interventionLinkSuccess.value = ''
@@ -323,7 +202,6 @@ const handleRevokeInterventionLink = async () => {
if (res.ok) { if (res.ok) {
interventionLink.value = '' interventionLink.value = ''
interventionLinkExpiresAt.value = '' interventionLinkExpiresAt.value = ''
interventionPublicLink.value = ''
interventionLinkSuccess.value = 'Link intervento revocato.' interventionLinkSuccess.value = 'Link intervento revocato.'
isRevokeInterventionModalOpen.value = false isRevokeInterventionModalOpen.value = false
} }
@@ -336,17 +214,13 @@ const handleRevokeInterventionLink = async () => {
} }
const handleSendMessage = async (msgText: string, files: File[]) => { const handleSendMessage = async (msgText: string, files: File[]) => {
if (!canSendMessage.value) return
if (!msgText.trim() && files.length === 0) return if (!msgText.trim() && files.length === 0) return
isSubmittingMessage.value = true isSubmittingMessage.value = true
clearActionFeedback()
try { try {
await addMessage(reportId, { body: msgText }) await addMessage(reportId, { body: msgText })
await refreshMessages() await refreshMessages()
actionSuccess.value = 'Messaggio inviato.' } catch (e) {
} catch (e: any) {
console.error('Failed to send message', e) console.error('Failed to send message', e)
actionError.value = e?.data?.message || 'Invio messaggio non riuscito.'
} finally { } finally {
isSubmittingMessage.value = false isSubmittingMessage.value = false
} }
@@ -354,6 +228,7 @@ const handleSendMessage = async (msgText: string, files: File[]) => {
const canFlagMessage = (message: Message) => { const canFlagMessage = (message: Message) => {
if (!user.value) return false if (!user.value) return false
if (isAdmin(user.value?.role)) return false
const isCitizenMessage = message.user_id !== user.value.id && message.user_id === report.value?.user_id const isCitizenMessage = message.user_id !== user.value.id && message.user_id === report.value?.user_id
const isAlreadyFlagged = flaggedMessageIds.value.includes(message.id) const isAlreadyFlagged = flaggedMessageIds.value.includes(message.id)
@@ -377,44 +252,16 @@ const executeFlagMessage = async () => {
}) })
flaggedMessageIds.value.push(messageToFlag.value.id) flaggedMessageIds.value.push(messageToFlag.value.id)
isFlagMessageModalOpen.value = false isFlagMessageModalOpen.value = false
messageToFlag.value = null messageToFlag.value = null
actionSuccess.value = 'Messaggio segnalato alla moderazione.' } catch (e) {
} catch (e: any) {
console.error('Failed to flag message', e) console.error('Failed to flag message', e)
actionError.value = e?.data?.message || 'Segnalazione messaggio non riuscita.'
} finally { } finally {
isFlaggingMessage.value = false isFlaggingMessage.value = false
} }
} }
const executeFlagReport = async () => {
if (!reportFlagForm.reason.trim()) {
reportFlagError.value = 'Inserisci un motivo.'
return
}
isFlaggingReport.value = true
reportFlagError.value = ''
reportFlagSuccess.value = ''
try {
await flagReport(reportId, {
reason: reportFlagForm.reason.trim(),
details: reportFlagForm.details.trim() || undefined
})
reportFlagSuccess.value = 'Segnalazione inviata alla moderazione.'
isFlagReportModalOpen.value = false
resetReportFlagForm()
} catch (e: any) {
console.error('Failed to flag report', e)
reportFlagError.value = e?.data?.message || 'Invio segnalazione non riuscito.'
} finally {
isFlaggingReport.value = false
}
}
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
if (!dateString) return '-' if (!dateString) return '-'
return new Date(dateString).toLocaleDateString('it-IT', { return new Date(dateString).toLocaleDateString('it-IT', {
@@ -441,15 +288,14 @@ const formattedInterventionLinkExpiry = computed(() =>
<div v-if="report"> <div v-if="report">
<div class="report-hero py-4"> <div class="report-hero py-4">
<div class="container"> <div class="container">
<NuxtLink :to="isTakenByCurrentOperator ? '/operator/assigned' : '/operator'" class="btn btn-sm btn-link text-decoration-none ps-0 mb-2"> <NuxtLink to="/operator/assigned" class="btn btn-sm btn-link text-decoration-none ps-0 mb-2">
&larr; Torna all'elenco &larr; Torna alle assegnazioni
</NuxtLink> </NuxtLink>
<div class="row g-3 align-items-end"> <div class="row g-3 align-items-end">
<div class="col-12 col-lg-8"> <div class="col-12 col-lg-8">
<div class="report-hero-meta mb-2"> <div class="report-hero-meta mb-2">
<span class="report-id">Segnalazione #{{ reportId }}</span> <span class="report-id">Intervento #{{ reportId }}</span>
<StatusBadge :status="report.status" /> <StatusBadge :status="report.status" />
<span v-if="isReadOnlyPreview" class="badge bg-secondary">Sola lettura</span>
</div> </div>
<h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1> <h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1>
<p class="mb-0 report-hero-address">{{ report.address }}</p> <p class="mb-0 report-hero-address">{{ report.address }}</p>
@@ -462,26 +308,15 @@ const formattedInterventionLinkExpiry = computed(() =>
</div> </div>
<div class="hero-stat"> <div class="hero-stat">
<span class="hero-stat-label">Aperta</span> <span class="hero-stat-label">Aperta</span>
<strong>{{ formatDate(report.created_at || '') }}</strong> <strong>{{ formatDate(report.created_at) }}</strong>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="isReadOnlyPreview" class="alert alert-info mt-3 mb-0">
Stai visualizzando segnalazione in sola lettura. Prendila in carico per aggiornare stato, assegnare team o scrivere al cittadino.
</div>
</div> </div>
</div> </div>
<div class="container my-5"> <div class="container my-5">
<div v-if="actionError" class="alert alert-danger mb-4">
{{ actionError }}
</div>
<div v-if="actionSuccess" class="alert alert-success mb-4">
{{ actionSuccess }}
</div>
<div class="row g-4"> <div class="row g-4">
<div class="col-12 col-xl-7"> <div class="col-12 col-xl-7">
<div class="card card-bg border-0 shadow-sm mb-4"> <div class="card card-bg border-0 shadow-sm mb-4">
@@ -522,20 +357,6 @@ const formattedInterventionLinkExpiry = computed(() =>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-warning btn-sm" @click="isFlagReportModalOpen = true">
Segnala alla moderazione
</button>
<button v-if="canTakeReport" type="button" class="btn btn-primary btn-sm" :disabled="isTakingReport" @click="handleTakeReport">
<span v-if="isTakingReport" class="spinner-border spinner-border-sm me-2"></span>
Prendi in carico
</button>
</div>
<div v-if="reportFlagSuccess" class="alert alert-success mt-3 mb-0">
{{ reportFlagSuccess }}
</div>
</div> </div>
</div> </div>
@@ -557,13 +378,7 @@ const formattedInterventionLinkExpiry = computed(() =>
:is-flagging-message-id="isFlaggingMessageId" :is-flagging-message-id="isFlaggingMessageId"
@flag="handleFlagMessage" @flag="handleFlagMessage"
/> />
<div v-if="isReadOnlyPreview" class="alert alert-secondary mt-3 mb-0 small"> <MessageComposer @send="handleSendMessage" />
{{ isFinalStatus ? 'Messaggistica non disponibile per segnalazioni concluse o rifiutate.' : 'Messaggistica disponibile dopo la presa in carico.' }}
</div>
<MessageComposer v-else-if="canSendMessage" @send="handleSendMessage" />
<div v-else class="alert alert-secondary mt-3 mb-0 small">
Messaggistica non disponibile per segnalazioni concluse o rifiutate.
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -579,8 +394,8 @@ const formattedInterventionLinkExpiry = computed(() =>
<strong>{{ summary.assigned_operator_id ? `#${summary.assigned_operator_id}` : 'Nessuno' }}</strong> <strong>{{ summary.assigned_operator_id ? `#${summary.assigned_operator_id}` : 'Nessuno' }}</strong>
</li> </li>
<li> <li>
<span>Team suggeriti</span> <span>Team disponibili</span>
<strong :class="{'text-primary': suggestedTeams.length > 0}">{{ suggestedTeams.length }}</strong> <strong>{{ teams.length }}</strong>
</li> </li>
<li> <li>
<span>Allegati</span> <span>Allegati</span>
@@ -594,13 +409,10 @@ const formattedInterventionLinkExpiry = computed(() =>
</p> </p>
</div> </div>
</div> </div>
<div class="card-body pt-0" v-if="!isReadOnlyPreview"> <div v-if="!isAdmin(user?.role)" class="card-body pt-0">
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="!canAssignTeam"> <button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="teams.length === 0">
Assegna team Assegna team
</button> </button>
<div v-if="isFinalStatus" class="small text-muted mt-2">
Assegnazione team non disponibile per segnalazioni concluse o rifiutate.
</div>
</div> </div>
</div> </div>
@@ -616,18 +428,18 @@ const formattedInterventionLinkExpiry = computed(() =>
{{ interventionLinkSuccess }} {{ interventionLinkSuccess }}
</div> </div>
<template v-if="interventionPublicLink"> <template v-if="interventionLink">
<label class="form-label">URL attivo</label> <label class="form-label">URL attivo</label>
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" class="form-control" :value="interventionPublicLink" readonly /> <input type="text" class="form-control" :value="interventionLink" readonly />
<button type="button" class="btn btn-outline-primary" @click="copyInterventionLink"> <button type="button" class="btn btn-outline-primary" @click="copyInterventionLink">
{{ copyFeedback || 'Copia' }} Copia
</button> </button>
</div> </div>
<p class="small text-muted mb-3"> <p class="small text-muted mb-3">
Scade: {{ formattedInterventionLinkExpiry || '-' }} Scade: {{ formattedInterventionLinkExpiry || '-' }}
</p> </p>
<div class="d-grid gap-2" v-if="!isReadOnlyPreview"> <div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary" :disabled="isGeneratingInterventionLink || !canCreateInterventionLink" @click="handleCreateInterventionLink"> <button type="button" class="btn btn-outline-primary" :disabled="isGeneratingInterventionLink || !canCreateInterventionLink" @click="handleCreateInterventionLink">
<span v-if="isGeneratingInterventionLink" class="spinner-border spinner-border-sm me-2"></span> <span v-if="isGeneratingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
Rigenera link Rigenera link
@@ -651,7 +463,7 @@ const formattedInterventionLinkExpiry = computed(() =>
</button> </button>
<div v-if="!canCreateInterventionLink" class="text-danger small mt-2"> <div v-if="!canCreateInterventionLink" class="text-danger small mt-2">
È necessaria una presa in carico e un'assegnazione in corso per generare questo link. È necessaria un'assegnazione in corso per generare o rigenerare questo link.
</div> </div>
</div> </div>
</div> </div>
@@ -660,13 +472,7 @@ const formattedInterventionLinkExpiry = computed(() =>
<div class="card-body"> <div class="card-body">
<h2 class="h6 mb-3">Aggiorna stato</h2> <h2 class="h6 mb-3">Aggiorna stato</h2>
<template v-if="isReadOnlyPreview"> <template v-if="selectableStatuses.length > 0">
<div class="alert alert-secondary mb-0 small">
Aggiornamento stato disponibile dopo la presa in carico.
</div>
</template>
<template v-else-if="canChangeStatus">
<form @submit.prevent="handleStatusUpdate"> <form @submit.prevent="handleStatusUpdate">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Nuovo Stato</label> <label class="form-label">Nuovo Stato</label>
@@ -717,53 +523,32 @@ const formattedInterventionLinkExpiry = computed(() =>
</div> </div>
</div> </div>
<!-- BACKGROUND MODALE COMUNE --> <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 || isFlagReportModalOpen" class="modal-backdrop fade show d-block" <div v-if="isAssignModalOpen" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<!-- MODALE: GESTIONE INTERVENTO -->
<div v-if="isAssignModalOpen" class="modal fade show d-block" style="z-index: 1050; overflow-y: auto;"
tabindex="-1">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Gestione Intervento</h5> <h5 class="modal-title">Gestione Intervento</h5>
<button type="button" class="btn-close" @click="isAssignModalOpen = false"></button> <button type="button" class="btn-close" @click="isAssignModalOpen = false"></button>
</div> </div>
<!-- FORM AGGIORNATO CON MODAL-BODY E MODAL-FOOTER -->
<form @submit.prevent="handleAssignTeam">
<div class="modal-body"> <div class="modal-body">
<div class="mb-0"> <form @submit.prevent="handleAssignTeam">
<div class="mb-3">
<label class="form-label">Team incaricato</label> <label class="form-label">Team incaricato</label>
<select v-model.number="selectedTeamId" class="form-select" required> <select v-model.number="selectedTeamId" class="form-select" required>
<option :value="null">Seleziona team</option> <option :value="null">Seleziona team</option>
<option v-for="team in teams" :key="team.id" :value="team.id">
<optgroup v-if="suggestedTeams.length > 0" label="Team Suggeriti"> {{ team.name }}<template v-if="team.category_name"> · {{ team.category_name }}</template>
<option v-for="team in suggestedTeams" :key="'sug-' + team.id" :value="Number(team.id)">
{{ team.name }}
<template v-if="team.is_recommended"> ★</template>
<template v-if="team.category_name"> · {{ team.category_name }}</template>
<template v-if="team.area_name"> · {{ team.area_name }}</template> <template v-if="team.area_name"> · {{ team.area_name }}</template>
</option> </option>
</optgroup>
<optgroup :label="suggestedTeams.length > 0 ? 'Altri Team' : 'Tutti i Team'">
<option v-for="team in otherTeams" :key="'oth-' + team.id" :value="Number(team.id)">
{{ team.name }}
<template v-if="team.category_name"> · {{ team.category_name }}</template>
<template v-if="team.area_name"> · {{ team.area_name }}</template>
</option>
</optgroup>
</select> </select>
<small v-if="allTeams.length === 0" class="form-text text-danger mt-1"> <small v-if="teams.length === 0" class="form-text text-danger">
Nessun team attivo disponibile. Nessun team attivo disponibile.
</small> </small>
</div> </div>
</div> <div class="d-flex justify-content-end gap-2 mt-4">
<div class="modal-footer"> <button type="button" class="btn btn-outline-secondary" @click="isAssignModalOpen = false">Annulla</button>
<button class="btn btn-outline-secondary" type="button" @click="isAssignModalOpen = false">Annulla <button type="submit" class="btn btn-primary" :disabled="isAssigning || !selectedTeamId">
</button>
<button :disabled="isAssigning || !selectedTeamId" class="btn btn-primary" type="submit">
<span v-if="isAssigning" class="spinner-border spinner-border-sm me-2"></span> <span v-if="isAssigning" class="spinner-border spinner-border-sm me-2"></span>
Salva Assegnazione Salva Assegnazione
</button> </button>
@@ -772,36 +557,6 @@ const formattedInterventionLinkExpiry = computed(() =>
</div> </div>
</div> </div>
</div> </div>
<!-- MODALE: SEGNALA ALLA MODERAZIONE -->
<div v-if="isFlagReportModalOpen" class="modal fade show d-block" style="z-index: 1050; overflow-y: auto;"
tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Segnala alla moderazione</h5>
<button type="button" class="btn-close" :disabled="isFlaggingReport" @click="isFlagReportModalOpen = false; resetReportFlagForm()"></button>
</div>
<div class="modal-body">
<div v-if="reportFlagError" class="alert alert-danger py-2">{{ reportFlagError }}</div>
<div class="mb-3">
<label class="form-label">Motivo</label>
<input v-model="reportFlagForm.reason" type="text" class="form-control" maxlength="200" />
</div>
<div class="mb-0">
<label class="form-label">Dettagli</label>
<textarea v-model="reportFlagForm.details" class="form-control" rows="4" placeholder="Dettagli aggiuntivi opzionali"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" :disabled="isFlaggingReport" @click="isFlagReportModalOpen = false; resetReportFlagForm()">Annulla</button>
<button type="button" class="btn btn-warning" :disabled="isFlaggingReport || !reportFlagForm.reason.trim()" @click="executeFlagReport">
<span v-if="isFlaggingReport" class="spinner-border spinner-border-sm me-2"></span>
Invia a moderazione
</button>
</div>
</div>
</div>
</div> </div>
<ConfirmModal <ConfirmModal
+1 -2
View File
@@ -130,8 +130,7 @@ const formatDate = (dateString: string) =>
<div class="card-body"> <div class="card-body">
<h2 class="h6 card-title mb-3">Azioni account</h2> <h2 class="h6 card-title mb-3">Azioni account</h2>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<NuxtLink v-if="isCitizen(user?.role)" class="btn btn-primary" to="/reports/new">Nuova segnalazione <NuxtLink to="/reports/new" class="btn btn-primary">Nuova segnalazione</NuxtLink>
</NuxtLink>
<NuxtLink to="/notifications" class="btn btn-outline-primary">Notifiche</NuxtLink> <NuxtLink to="/notifications" class="btn btn-outline-primary">Notifiche</NuxtLink>
<button type="button" class="btn btn-outline-danger" @click="logout"> <button type="button" class="btn btn-outline-danger" @click="logout">
Logout Logout
+6 -62
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
}) })
@@ -36,19 +34,6 @@ const isFeedbackOpen = ref(false)
const feedbackRating = ref(5) const feedbackRating = ref(5)
const feedbackComment = ref('') const feedbackComment = ref('')
const isSubmittingFeedback = ref(false) const isSubmittingFeedback = ref(false)
const submittedFeedback = ref<{ rating: number; comment?: string } | null>(null)
// Carica l'eventuale feedback da local storage dopo il mount (per evitare mismatch nel SSR)
onMounted(() => {
try {
const saved = localStorage.getItem(`feedback_${reportId}`)
if (saved) {
submittedFeedback.value = JSON.parse(saved)
}
} catch (error) {
console.error('Errore nel recupero del feedback da local storage:', error)
}
})
const handleSendMessage = async (messageText: string, files: File[]) => { const handleSendMessage = async (messageText: string, files: File[]) => {
isSubmittingMessage.value = true isSubmittingMessage.value = true
@@ -66,18 +51,13 @@ const handleSendMessage = async (messageText: string, files: File[]) => {
const handleSubmitFeedback = async () => { const handleSubmitFeedback = async () => {
isSubmittingFeedback.value = true isSubmittingFeedback.value = true
try { try {
const feedbackData = { await addFeedback(reportId, {
rating: feedbackRating.value, rating: feedbackRating.value,
comment: feedbackComment.value || undefined, comment: feedbackComment.value || undefined,
} })
await addFeedback(reportId, feedbackData)
// Salva in local storage in mancanza di un endpoint dal backend
localStorage.setItem(`feedback_${reportId}`, JSON.stringify(feedbackData))
submittedFeedback.value = feedbackData
isFeedbackOpen.value = false isFeedbackOpen.value = false
feedbackRating.value = 5
feedbackComment.value = ''
await refreshReport() await refreshReport()
} catch (error) { } catch (error) {
console.error('Error submitting feedback:', error) console.error('Error submitting feedback:', error)
@@ -89,12 +69,6 @@ const handleSubmitFeedback = async () => {
const isOwner = computed(() => report.value?.user_id === user.value?.id) const isOwner = computed(() => report.value?.user_id === user.value?.id)
const canFeedback = computed(() => report.value?.status === 'RESOLVED') const canFeedback = computed(() => report.value?.status === 'RESOLVED')
const canChat = computed(() => {
if (!report.value) return false
const closedStatuses = ['RESOLVED', 'CLOSED', 'REJECTED']
return !closedStatuses.includes(report.value.status)
})
const statusMeta: Record<string, { label: string; tone: string }> = { const statusMeta: Record<string, { label: string; tone: string }> = {
OPEN: { label: 'Aperta', tone: 'info' }, OPEN: { label: 'Aperta', tone: 'info' },
UNDER_REVIEW: { label: 'In revisione', tone: 'warning' }, UNDER_REVIEW: { label: 'In revisione', tone: 'warning' },
@@ -102,7 +76,7 @@ const statusMeta: Record<string, { label: string; tone: string }> = {
IN_PROGRESS: { label: 'In lavorazione', tone: 'primary' }, IN_PROGRESS: { label: 'In lavorazione', tone: 'primary' },
RESOLVED: { label: 'Risolta', tone: 'success' }, RESOLVED: { label: 'Risolta', tone: 'success' },
REJECTED: { label: 'Respinta', tone: 'danger' }, REJECTED: { label: 'Respinta', tone: 'danger' },
CLOSED: { label: 'Conclusa', tone: 'secondary' }, CLOSED: { label: 'Chiusa', tone: 'secondary' },
} }
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
@@ -236,12 +210,7 @@ const formatDate = (dateString: string): string => {
<MessageList :messages="messages"/> <MessageList :messages="messages"/>
<MessageComposer v-if="canChat" @send="handleSendMessage"/> <MessageComposer @send="handleSendMessage"/>
<div v-else class="alert alert-secondary mt-3 mb-0 text-center small">
La chat è disabilitata poiché la segnalazione è in stato
<strong>{{ statusMeta[report.status]?.label || report.status }}</strong>.
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -271,30 +240,6 @@ const formatDate = (dateString: string): string => {
<div v-if="canFeedback && isOwner" class="card card-bg border-0 shadow-sm mb-4"> <div v-if="canFeedback && isOwner" class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
<h2 class="h6 mb-3">Valuta intervento</h2> <h2 class="h6 mb-3">Valuta intervento</h2>
<template v-if="submittedFeedback">
<div class="alert alert-success small mb-3">
Grazie! Il tuo feedback è stato inviato con successo.
</div>
<div class="mb-3">
<span class="d-block text-muted small mb-1">La tua valutazione</span>
<div class="d-flex gap-1">
<BIIcon
v-for="n in 5"
:key="n"
:name="n <= submittedFeedback.rating ? 'it-star-full' : 'it-star-outline'"
size="sm"
:color="n <= submittedFeedback.rating ? 'warning' : 'primary'"
/>
</div>
</div>
<div v-if="submittedFeedback.comment">
<span class="d-block text-muted small mb-1">Il tuo commento</span>
<p class="mb-0 small text-break">{{ submittedFeedback.comment }}</p>
</div>
</template>
<template v-else>
<p class="text-muted small mb-3">Lascia valutazione finale quando intervento è concluso.</p> <p class="text-muted small mb-3">Lascia valutazione finale quando intervento è concluso.</p>
<button <button
v-if="!isFeedbackOpen" v-if="!isFeedbackOpen"
@@ -340,7 +285,6 @@ const formatDate = (dateString: string): string => {
</button> </button>
</div> </div>
</template> </template>
</template>
</div> </div>
</div> </div>
+3 -42
View File
@@ -6,11 +6,10 @@ export default defineNuxtConfig({
'@nuxt/image', '@nuxt/image',
'@nuxt/fonts', '@nuxt/fonts',
'@nuxtjs/sitemap', '@nuxtjs/sitemap',
'@nuxtjs/robots', '@nuxtjs/robots'
'@vite-pwa/nuxt'
], ],
// TODO: riattivare prima di pubblicare il sito
ssr: true, ssr: false,
runtimeConfig: { runtimeConfig: {
public: { public: {
@@ -40,47 +39,9 @@ export default defineNuxtConfig({
app: { app: {
head: { head: {
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0' },
{ name: 'theme-color', content: '#003366' },
{ name: 'description', content: 'Visione - Un\'occhio per la sicurezza' },
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
{ name: 'apple-mobile-web-app-title', content: 'Visione' }
],
link: [ link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/icon.svg' } { rel: 'icon', type: 'image/svg+xml', href: '/icon.svg' }
] ]
} }
},
pwa: {
registerType: 'autoUpdate',
manifest: {
name: 'Visione - Un\'occhio per la sicurezza',
short_name: 'Visione',
theme_color: '#003366',
background_color: '#003366',
display: 'standalone',
orientation: 'portrait',
icons: [
{
src: '/icon-192.webp',
sizes: '192x192',
type: 'image/webp',
purpose: 'any'
},
{
src: '/icon-512.webp',
sizes: '512x512',
type: 'image/webp',
purpose: 'any'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,png,svg,ico,webp}']
}
} }
}) })
-1
View File
@@ -14,7 +14,6 @@
"@nuxt/image": "^2.0.0", "@nuxt/image": "^2.0.0",
"@nuxtjs/robots": "6.0.9", "@nuxtjs/robots": "6.0.9",
"@nuxtjs/sitemap": "8.0.15", "@nuxtjs/sitemap": "8.0.15",
"@vite-pwa/nuxt": "1.1.1",
"bootstrap-italia": "^2.18.1", "bootstrap-italia": "^2.18.1",
"codice-fiscale-js": "^2.3.23", "codice-fiscale-js": "^2.3.23",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

+211 -3195
View File
File diff suppressed because it is too large Load Diff