77 Commits
64 changed files with 6094 additions and 1911 deletions
+103 -53
View File
@@ -1,75 +1,125 @@
# Nuxt Minimal Starter <div align="center">
<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>
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. **Visione** è una piattaforma progettata per gestire le segnalazioni dei cittadini, implementando un flusso operativo
sicuro, trasparente e istituzionalmente conforme.
## Setup ## Punti di Forza e Funzionalità Principali
Make sure to install dependencies: - **Conformità Istituzionale e UX/UI Standardizzata:** Il frontend è sviluppato utilizzando **Bootstrap Italia** (creato
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
``` ```
## Development Server ### 2. Configurazione Variabili d'Ambiente (.env)
Start the development server on `http://localhost:3000`: Copia il file di esempio per creare la tua configurazione locale:
```bash ```bash
# npm cp .env.example .env
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
``` ```
## Production Il file `.env` richiede le seguenti chiavi:
Build the application for production: - `NUXT_PUBLIC_URL`: URL pubblico dell'applicativo frontend (es. `http://localhost:3000`).
- `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
# npm yarn run dev
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
``` ```
Locally preview production build: Il server sarà accessibile all'indirizzo [http://localhost:3000](http://localhost:3000).
---
## 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
# npm docker compose up -d --build
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
``` ```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. ### Dettagli sul Dockerfile
- **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).
+3 -2
View File
@@ -1,14 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
useSeoMeta({ useSeoMeta({
title: "Visione - UniBA", title: "Visione",
ogTitle: "Visione - UniBA", ogTitle: "Visione",
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>
+8
View File
@@ -376,3 +376,11 @@ html body .navbar .navbar-collapsable .navbar-nav li.nav-item a.nav-link[data-fo
max-width: 14rem; max-width: 14rem;
} }
} }
.alert-danger {
background-position-y: center;
}
.card::after {
margin-top: 0!important;
}
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
confirmClass?: string
pending?: boolean
}>(), {
confirmLabel: 'Conferma',
cancelLabel: 'Annulla',
confirmClass: 'btn-danger',
pending: false,
})
const emit = defineEmits<{
cancel: []
confirm: []
}>()
const handleBackdropClick = (event: MouseEvent) => {
if (event.target === event.currentTarget && !props.pending) {
emit('cancel')
}
}
</script>
<template>
<div v-if="open">
<div class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<div
class="modal fade show d-block"
tabindex="-1"
style="z-index: 1050;"
@click="handleBackdropClick"
>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content shadow">
<div class="modal-header">
<h5 class="modal-title">{{ title }}</h5>
<button type="button" class="btn-close" :disabled="pending" @click="emit('cancel')"></button>
</div>
<div class="modal-body">
<p class="mb-0">{{ message }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" :disabled="pending" @click="emit('cancel')">
{{ cancelLabel }}
</button>
<button type="button" class="btn" :class="confirmClass" :disabled="pending" @click="emit('confirm')">
<span v-if="pending" class="spinner-border spinner-border-sm me-2"></span>
{{ confirmLabel }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
+252 -25
View File
@@ -1,44 +1,58 @@
<script setup lang="ts"> <script setup lang="ts">
import MemberDot from "~/components/MemberDot.vue";
import BIIcon from "~/components/BIIcon.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"> <div class="container footer-main-content">
<section> <section class="footer-brand-section">
<div class="row clearfix">
<div class="col-sm-12">
<div class="it-brand-wrapper"> <div class="it-brand-wrapper">
<a href="#" data-focus-mouse="false" class="d-flex align-items-center"> <NuxtLink to="/" data-focus-mouse="false" class="footer-brand-link">
<img src="/icon.svg" alt="Logo Visione" style="width: 42px; height: 42px; margin-right: 12px;" /> <img alt="Logo Visione" class="footer-brand-logo" src="/logo-light.svg"/>
<div class="it-brand-text"> <div class="it-brand-text">
<h2>Visione</h2> <h2>Visione</h2>
<h3 class="d-none d-md-block">Segnalazioni anonime e sicure</h3> <h3>Un occhio per la sicurezza di Bari</h3>
</div>
</a>
</div>
</div> </div>
</NuxtLink>
</div> </div>
</section> </section>
<section class="py-4 border-white border-top">
<div class="row"> <section class="footer-content-section">
<div class="col-12 pb-2"> <div class="footer-support">
<div class="pb-2"> <h4>Assistenza per i cittadini</h4>
<h4>Progetto di</h4> <ul class="footer-support-list">
<div class="members-list d-flex justify-content-center align-items-center gap-5 flex-wrap w-100"> <li v-for="item in supportItems" :key="item.label">
<MemberDot <span class="footer-support-label">{{ item.label }}:</span>
<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"
:name="member.name" class="footer-member-card"
:image="member.image" >
/> <img :src="member.image" :alt="member.name" class="footer-member-image">
</div> <div class="footer-member-name">{{ member.name }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -47,12 +61,225 @@ const { sortedMembers } = useMembers();
</div> </div>
<div class="it-footer-small-prints clearfix"> <div class="it-footer-small-prints clearfix">
<div class="container"> <div class="container">
<ul class="it-footer-small-prints-list list-inline mb-0 d-flex flex-column flex-md-row"> <div class="footer-lower-bar">
<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>
+148 -25
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import BIIcon from "~/components/BIIcon.vue"; import BIIcon from "~/components/BIIcon.vue";
@@ -10,15 +10,31 @@ watch(() => route.path, () => {
isMobileMenuOpen.value = false isMobileMenuOpen.value = false
}) })
watch(isMobileMenuOpen, (isOpen) => {
if (typeof document !== 'undefined') {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
}
})
onUnmounted(() => {
if (typeof document !== 'undefined') {
document.body.style.overflow = ''
}
})
const { isAuthenticated, user, logout, hasUnreadNotifications } = useAuth()
const panelLabel = computed(() => isAdmin(user.value?.role) ? 'Pannello' : 'Dashboard')
const navLinks = [ const navLinks = [
{ name: 'Home', path: '/' }, { name: 'Home', path: '/' },
{ name: 'Fai una segnalazione', path: '/reports/new' }, ...(isCitizen(user.value?.role) ? [{ name: 'Fai una segnalazione', path: '/reports/new' }] : []),
{ name: 'Esplora i dati', path: '/open-data' }, { name: 'Esplora i dati', path: '/open-data' },
{ name: 'Come funziona', path: '/info' } { name: 'Come funziona', path: '/info' }
] ]
const { isAuthenticated, user, logout } = useAuth()
const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ? 'Pannello' : 'Dashboard')
</script> </script>
<template> <template>
@@ -28,8 +44,7 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="it-header-slim-wrapper-content"> <div class="it-header-slim-wrapper-content">
<a class="d-none d-lg-block navbar-brand" <a class="d-none d-lg-block navbar-brand" href="https://www.cittametropolitana.ba.it/">
href="https://www.cittametropolitana.ba.it/">
Città metropolitana di Bari Città metropolitana di Bari
</a> </a>
<div class="nav-mobile"> <div class="nav-mobile">
@@ -48,23 +63,24 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
<NuxtLink class="btn btn-primary btn-icon btn-full" to="/dashboard" <NuxtLink class="btn btn-primary btn-icon btn-full" to="/dashboard"
data-element="personal-area-dashboard" data-focus-mouse="false"> data-element="personal-area-dashboard" data-focus-mouse="false">
<span class="rounded-icon" aria-hidden="true"> <span class="rounded-icon" aria-hidden="true">
<BIIcon name="it-list" color="black" /> <BIIcon name="it-list" color="black"/>
</span> </span>
<span class="d-none d-lg-block">{{ panelLabel }}</span> <span class="d-none d-lg-block">{{ panelLabel }}</span>
</NuxtLink> </NuxtLink>
<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" aria-hidden="true"> <span class="rounded-icon profile-icon-wrap" 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">{{ user?.first_name }}</span> <span class="d-none d-lg-block username">{{ 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"
data-element="personal-area-logout" data-focus-mouse="false"> data-element="personal-area-logout" data-focus-mouse="false">
<span class="rounded-icon" aria-hidden="true"> <span class="rounded-icon" aria-hidden="true">
<BIIcon name="it-logout" color="black" /> <BIIcon name="it-logout" color="black"/>
</span> </span>
<span class="d-none d-lg-block">Esci</span> <span class="d-none d-lg-block">Esci</span>
</button> </button>
@@ -73,12 +89,11 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
<NuxtLink v-else class="btn btn-primary btn-icon btn-full" to="/auth/login" <NuxtLink v-else class="btn btn-primary btn-icon btn-full" to="/auth/login"
data-element="personal-area-login" data-focus-mouse="false"> data-element="personal-area-login" data-focus-mouse="false">
<span class="rounded-icon" aria-hidden="true"> <span class="rounded-icon" aria-hidden="true">
<BIIcon name="it-user" color="black" /> <BIIcon name="it-user" color="black"/>
</span> </span>
<span class="d-none d-lg-block">Login</span> <span class="d-none d-lg-block">Login</span>
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -93,7 +108,7 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
<div class="it-header-center-content-wrapper"> <div class="it-header-center-content-wrapper">
<div class="it-brand-wrapper"> <div class="it-brand-wrapper">
<NuxtLink to="/"> <NuxtLink to="/">
<img src="/icon.svg" alt="Logo Visione" style="width: 48px; height: 48px; margin-right: 16px;" /> <img alt="Logo Visione" class="logo" src="/logo-light.svg"/>
<div class="it-brand-text"> <div class="it-brand-text">
<div class="it-brand-title">Visione</div> <div class="it-brand-title">Visione</div>
<div class="it-brand-tagline d-none d-md-block">Un occhio per la sicurezza del cittadino.</div> <div class="it-brand-tagline d-none d-md-block">Un occhio per la sicurezza del cittadino.</div>
@@ -111,6 +126,7 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<nav class="navbar navbar-expand-lg has-megamenu" aria-label="Navigazione principale"> <nav class="navbar navbar-expand-lg has-megamenu" aria-label="Navigazione principale">
<button <button
class="custom-navbar-toggler" class="custom-navbar-toggler"
type="button" type="button"
@@ -124,19 +140,16 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
class="overlay" class="overlay"
:class="{ 'show': isMobileMenuOpen }" :class="{ 'show': isMobileMenuOpen }"
@click="isMobileMenuOpen = false" @click="isMobileMenuOpen = false"
style="display: block;"
v-show="isMobileMenuOpen"
></div> ></div>
<div <div
class="navbar-collapsable" class="navbar-collapsable"
:class="{ 'expanded': isMobileMenuOpen }" :class="{ 'expanded': isMobileMenuOpen }"
style="display: block;"
> >
<div class="close-div"> <div class="close-div">
<button class="btn close-menu" type="button" @click="isMobileMenuOpen = false"> <button class="btn close-menu" type="button" @click="isMobileMenuOpen = false">
<span class="visually-hidden">Nascondi la navigazione</span> <span class="visually-hidden">Nascondi la navigazione</span>
<BIIcon name="it-close-big"/> <BIIcon name="it-close-big" color="black"/>
</button> </button>
</div> </div>
@@ -184,14 +197,124 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
background-color: rgb(0, 76.5, 153); background-color: rgb(0, 76.5, 153);
} }
.overlay { @media (max-width: 991.98px) {
transition: opacity 0.3s ease; .overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.6) !important;
z-index: 1040;
opacity: 0; opacity: 0;
pointer-events: none; visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.overlay.show {
opacity: 1;
visibility: visible;
}
.navbar-collapsable {
position: fixed !important;
top: 0;
left: 0;
width: 320px;
max-width: 85vw;
height: 100vh;
background-color: #ffffff !important;
z-index: 1050 !important;
transform: translateX(-100%);
transition: transform 0.3s ease-in-out;
display: flex;
flex-direction: column;
box-shadow: 4px 0 15px rgba(0, 0, 0, 0.2);
overflow-y: auto;
}
.navbar-collapsable.expanded {
transform: translateX(0);
}
.close-div {
display: flex;
justify-content: flex-end;
padding: 1rem 1.5rem;
}
.close-menu {
background: transparent;
border: none;
padding: 0;
}
.close-menu :deep(svg) {
width: 32px;
height: 32px;
}
.menu-wrapper {
padding: 1rem 0;
}
.navbar-nav {
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
list-style: none;
}
.nav-item {
width: 100%;
}
.nav-link {
display: block;
padding: 1rem 1.5rem;
color: #1a1a1a !important;
font-size: 1.1rem;
font-weight: 600;
text-decoration: none;
border-bottom: 1px solid #f0f0f0;
}
.nav-link.active {
color: #004c99 !important;
background-color: #f8f9fa;
}
} }
.overlay.show { .btn-full {
opacity: 1; padding: 14px;
pointer-events: auto; }
.logo {
width: 48px;
height: 48px;
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>
+19 -18
View File
@@ -1,4 +1,4 @@
<script setup lang="ts"> <script lang="ts" setup>
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="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer position-relative"
:class="{ 'border-primary bg-primary bg-opacity-10': isDragging, 'bg-light': !isDragging }" :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"
@click="!disabled && openFileDialog()"
@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 name="it-upload" size="xl" color="primary" class="mb-3" /> <BIIcon class="mb-3" color="primary" name="it-upload" size="xl"/>
<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,26 +106,27 @@ 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/')" :src="getFileUrl(file)" class="object-fit-cover" :alt="file.name" /> <img v-if="file.type.startsWith('image/')" :alt="file.name" :src="getFileUrl(file)"
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 name="it-file" size="xl" color="secondary" /> <BIIcon color="secondary" name="it-file" size="xl"/>
</div> </div>
</div> </div>
<button <button
type="button" :disabled="disabled"
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;"
@click.stop="removeFile(idx)"
:disabled="disabled"
title="Rimuovi file" title="Rimuovi file"
type="button"
@click.stop="removeFile(idx)"
> >
<BIIcon name="it-close" size="sm" color="white" /> <BIIcon color="white" name="it-close" size="sm"/>
</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 class="small text-truncate fw-semibold" :title="file.name">{{ file.name }}</div> <div :title="file.name" class="small text-truncate fw-semibold">{{ 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">
@@ -143,27 +144,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>
+30 -120
View File
@@ -1,21 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
interface Emits { interface Emits {
(e: 'send', message: string, files: File[]): void (e: 'send', message: string): 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() === '' && files.value.length === 0) return if (message.value.trim() === '') return
emit('send', message.value, [...files.value]) emit('send', message.value)
message.value = '' message.value = ''
files.value = []
} }
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
@@ -24,76 +23,34 @@ 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="composer-shell">
<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-icon composer-attach"
title="Allega file"
@click="fileInputRef?.click()"
:disabled="isSubmitting || files.length >= 5"
>
<BIIcon name="it-clip" size="sm" />
</button>
<div class="d-flex align-items-stretch gap-2">
<textarea <textarea
v-model="message" v-model="message"
class="form-control composer-textarea" class="form-control chat-textarea m-0"
placeholder="Scrivi aggiornamento o domanda" placeholder="Scrivi aggiornamento o domanda"
rows="2"
:disabled="isSubmitting" :disabled="isSubmitting"
@keydown="handleKeydown" @keydown="handleKeydown"
/> ></textarea>
<button <button
type="submit" type="submit"
class="btn btn-primary composer-submit" class="btn btn-primary btn-square flex-shrink-0"
:disabled="isSubmitting || (message.trim() === '' && files.length === 0)" :disabled="isSubmitting || message.trim() === ''"
title="Invia"
> >
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span> <span v-if="isSubmitting" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<BIIcon v-else name="it-arrow-right" size="sm" color="white" class="me-1" /> <BIIcon v-else color="white" name="it-arrow-right" size="sm"/>
Invia
</button> </button>
</div> </div>
<small class="text-muted d-block mt-2"> <small class="text-muted d-block mt-2">
`Ctrl` + `Invio` per inviare. Fino a 5 allegati. `Ctrl` + `Invio` per inviare.
</small> </small>
</form> </form>
</div> </div>
@@ -105,75 +62,28 @@ const removeFile = (idx: number) => {
padding-top: 1rem; padding-top: 1rem;
} }
.message-files { .chat-textarea {
display: flex; resize: none !important;
flex-wrap: wrap; height: 48px;
gap: 0.5rem; min-height: 48px;
overflow-y: auto;
padding-top: 12px;
padding-bottom: 12px;
border-radius: 6px;
} }
.message-file-chip { .chat-textarea:focus {
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;
}
.composer-shell {
align-items: flex-end;
background: #fff;
border: 1px solid #c5d0db;
border-radius: 4px;
display: grid;
gap: 0.75rem;
grid-template-columns: auto minmax(0, 1fr) auto;
padding: 0.75rem;
}
.composer-shell:focus-within {
border-color: #06c; border-color: #06c;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12); box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
} }
.composer-attach { .btn-square {
flex-shrink: 0; width: 48px;
} height: 48px;
.composer-textarea {
border: 0;
box-shadow: none;
min-height: 4rem;
padding: 0; padding: 0;
resize: vertical; display: flex;
} align-items: center;
justify-content: center;
.composer-submit { border-radius: 6px;
min-width: 7rem;
}
.btn-close-sm {
font-size: 0.55rem;
}
@media (max-width: 767.98px) {
.composer-shell {
grid-template-columns: auto 1fr;
}
.composer-textarea {
grid-column: 1 / -1;
}
.composer-submit {
grid-column: 2;
justify-self: end;
}
} }
</style> </style>
+14 -10
View File
@@ -50,7 +50,7 @@ const senderInitials = (msg: Message): string =>
</div> </div>
<div v-else-if="messages.length === 0" class="message-empty-state" role="alert"> <div v-else-if="messages.length === 0" class="message-empty-state" role="alert">
<BIIcon name="it-comment" size="lg" color="primary" class="mb-2" /> <BIIcon class="mb-2" color="primary" name="it-comment" size="lg"/>
<strong class="d-block mb-1">Nessun messaggio</strong> <strong class="d-block mb-1">Nessun messaggio</strong>
<span>Usa chat per aggiornamenti rapidi tra cittadino e operatori.</span> <span>Usa chat per aggiornamenti rapidi tra cittadino e operatori.</span>
</div> </div>
@@ -59,18 +59,18 @@ const senderInitials = (msg: Message): string =>
<div <div
v-for="msg in messages" v-for="msg in messages"
:key="msg.id" :key="msg.id"
class="message-row"
:class="{ 'is-current-user': isCurrentUser(msg.user_id) }" :class="{ 'is-current-user': isCurrentUser(msg.user_id) }"
class="message-row"
> >
<div v-if="!isCurrentUser(msg.user_id)" class="message-avatar" aria-hidden="true"> <div v-if="!isCurrentUser(msg.user_id)" class="message-avatar" aria-hidden="true">
{{ senderInitials(msg) }} {{ senderInitials(msg) }}
</div> </div>
<div <div
class="message-bubble-content"
:class="{ :class="{
'message-bubble-outgoing': isCurrentUser(msg.user_id), 'message-bubble-outgoing': isCurrentUser(msg.user_id),
'message-bubble-incoming': !isCurrentUser(msg.user_id) 'message-bubble-incoming': !isCurrentUser(msg.user_id)
}" }"
class="message-bubble-content"
> >
<div class="message-meta"> <div class="message-meta">
<span v-if="!isCurrentUser(msg.user_id)" class="message-author"> <span v-if="!isCurrentUser(msg.user_id)" class="message-author">
@@ -90,15 +90,16 @@ const senderInitials = (msg: Message): string =>
{{ msg.body }} {{ msg.body }}
</div> </div>
<div v-if="canFlagMessage(msg)" class="message-actions"> <div v-if="canFlagMessage?.(msg)" class="message-actions">
<button <button
type="button"
class="btn btn-link btn-xs px-0"
:disabled="isFlaggingMessageId === msg.id" :disabled="isFlaggingMessageId === msg.id"
class="btn btn-link btn-xs px-0"
type="button"
@click="emit('flag', msg)" @click="emit('flag', msg)"
> >
<span v-if="isFlaggingMessageId === msg.id" class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> <span v-if="isFlaggingMessageId === msg.id" aria-hidden="true"
<BIIcon v-else name="it-flag" size="xs" class="me-1" /> class="spinner-border spinner-border-sm me-1"></span>
<BIIcon v-else class="me-1" name="it-flag" size="xs"/>
Segnala messaggio Segnala messaggio
</button> </button>
</div> </div>
@@ -112,9 +113,12 @@ const senderInitials = (msg: Message): string =>
.message-list { .message-list {
border: 1px solid #d9e3ee; border: 1px solid #d9e3ee;
border-radius: 4px; border-radius: 4px;
max-height: 34rem; height: 34rem;
overflow-y: auto; overflow-y: auto;
padding: 1rem; padding: 1rem;
display: flex;
flex-direction: column;
} }
.messages-container { .messages-container {
@@ -129,7 +133,7 @@ const senderInitials = (msg: Message): string =>
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
min-height: 14rem; flex: 1;
padding: 2rem 1rem; padding: 2rem 1rem;
text-align: center; text-align: center;
} }
-4
View File
@@ -57,7 +57,6 @@ const pageNumbers = computed(() => {
<template> <template>
<nav v-if="totalPages > 1" class="d-flex justify-content-center mt-5 mb-5"> <nav v-if="totalPages > 1" class="d-flex justify-content-center mt-5 mb-5">
<ul class="pagination mb-0"> <ul class="pagination mb-0">
<!-- Pagina Precedente -->
<li class="page-item" :class="{ disabled: currentPage === 1 }"> <li class="page-item" :class="{ disabled: currentPage === 1 }">
<button <button
class="page-link" class="page-link"
@@ -69,7 +68,6 @@ const pageNumbers = computed(() => {
</button> </button>
</li> </li>
<!-- Numeri Pagine -->
<li v-for="page in pageNumbers" :key="`page-${page}`" class="page-item" :class="{ active: page === currentPage }"> <li v-for="page in pageNumbers" :key="`page-${page}`" class="page-item" :class="{ active: page === currentPage }">
<template v-if="page === '...'"> <template v-if="page === '...'">
<span class="page-link">...</span> <span class="page-link">...</span>
@@ -86,7 +84,6 @@ const pageNumbers = computed(() => {
</template> </template>
</li> </li>
<!-- Pagina Successiva -->
<li class="page-item" :class="{ disabled: currentPage === totalPages }"> <li class="page-item" :class="{ disabled: currentPage === totalPages }">
<button <button
class="page-link" class="page-link"
@@ -100,7 +97,6 @@ const pageNumbers = computed(() => {
</ul> </ul>
</nav> </nav>
<!-- Info Pagina -->
<div v-if="totalPages > 1" class="text-center text-muted small mt-3"> <div v-if="totalPages > 1" class="text-center text-muted small mt-3">
Pagina {{ currentPage }} di {{ totalPages }} ({{ totalItems }} elementi totali) Pagina {{ currentPage }} di {{ totalPages }} ({{ totalItems }} elementi totali)
</div> </div>
+14 -27
View File
@@ -3,6 +3,7 @@ import type { Category, CreateReportRequest } from '~/types/api'
import type { LeafletMouseEvent, Map as LeafletMap, Marker as LeafletMarker } from 'leaflet' import type { LeafletMouseEvent, Map as LeafletMap, Marker as LeafletMarker } from 'leaflet'
import { onMounted, onUnmounted, ref, watch } from 'vue' import { onMounted, onUnmounted, ref, watch } from 'vue'
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari' import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
import { reverseGeocode, searchAddresses } from '~/utils/geocoding'
interface Props { interface Props {
isLoading?: boolean isLoading?: boolean
@@ -19,14 +20,6 @@ interface GeocodingSuggestion {
longitude: number longitude: number
} }
interface SearchAddressesResponse {
suggestions: GeocodingSuggestion[]
}
interface ReverseGeocodeResponse {
address: string
}
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
isLoading: false, isLoading: false,
categories: () => [] categories: () => []
@@ -52,7 +45,6 @@ const isResolvingAddress = ref(false)
const addressSearchError = ref('') const addressSearchError = ref('')
const showAddressSuggestions = ref(false) const showAddressSuggestions = ref(false)
// Map variables
const mapContainer = ref<HTMLElement | null>(null) const mapContainer = ref<HTMLElement | null>(null)
let leaflet: typeof import('leaflet') | null = null let leaflet: typeof import('leaflet') | null = null
let map: LeafletMap | null = null let map: LeafletMap | null = null
@@ -69,7 +61,6 @@ const loadLeaflet = async () => {
const leafletModule = await import('leaflet') const leafletModule = await import('leaflet')
// Fix per le icone di Leaflet in Nuxt/Vite
const defaultIcon = leafletModule.icon({ const defaultIcon = leafletModule.icon({
iconUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon.png', iconUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon-2x.png', iconRetinaUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon-2x.png',
@@ -123,18 +114,20 @@ const fetchAddressSuggestions = async (query: string) => {
addressSearchError.value = '' addressSearchError.value = ''
try { try {
const response = await $fetch<SearchAddressesResponse>('/api/geocoding/search', { if (requestId !== addressSearchRequestId) {
query: { q: query } return
}) }
const suggestions = await searchAddresses(query)
if (requestId !== addressSearchRequestId) { if (requestId !== addressSearchRequestId) {
return return
} }
addressSuggestions.value = response.suggestions addressSuggestions.value = suggestions
showAddressSuggestions.value = response.suggestions.length > 0 showAddressSuggestions.value = suggestions.length > 0
if (!response.suggestions.length) { if (!suggestions.length) {
addressSearchError.value = 'Nessun indirizzo trovato nel Comune di Bari.' addressSearchError.value = 'Nessun indirizzo trovato nel Comune di Bari.'
} }
} catch { } catch {
@@ -157,15 +150,11 @@ const resolveAddressFromCoordinates = async (lat: number, lng: number, fallbackA
addressSearchError.value = '' addressSearchError.value = ''
try { try {
const response = await $fetch<ReverseGeocodeResponse>('/api/geocoding/reverse', {
query: { lat, lng }
})
if (requestId !== reverseGeocodeRequestId) { if (requestId !== reverseGeocodeRequestId) {
return return
} }
const resolvedAddress = response.address?.trim() || fallbackAddress.trim() const resolvedAddress = (await reverseGeocode(lat, lng))?.trim() || fallbackAddress.trim()
if (resolvedAddress) { if (resolvedAddress) {
setAddressValue(resolvedAddress) setAddressValue(resolvedAddress)
@@ -285,7 +274,6 @@ const initMap = async () => {
void updateLocation(e.latlng.lat, e.latlng.lng) void updateLocation(e.latlng.lat, e.latlng.lng)
}) })
// Se c'è già una posizione, imposta il marker
if (form.value.latitude && form.value.longitude) { if (form.value.latitude && form.value.longitude) {
void updateLocation(form.value.latitude, form.value.longitude, { zoomTo: true }) void updateLocation(form.value.latitude, form.value.longitude, { zoomTo: true })
} }
@@ -444,7 +432,6 @@ watch(() => form.value.address, (newAddress) => {
}, 350) }, 350)
}) })
// Sincronizza input manuali con la mappa
watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => { watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => {
if (newLat && newLng && map && leaflet) { if (newLat && newLng && map && leaflet) {
if (!marker || marker.getLatLng().lat !== newLat || marker.getLatLng().lng !== newLng) { if (!marker || marker.getLatLng().lat !== newLat || marker.getLatLng().lng !== newLng) {
@@ -541,7 +528,7 @@ watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]
step="0.000001" step="0.000001"
class="form-control form-control-sm" class="form-control form-control-sm"
:class="{ 'is-invalid': errors.location }" :class="{ 'is-invalid': errors.location }"
placeholder="Latitudine (Bari)" placeholder="Latitudine (es. 45.5)"
required required
/> />
</div> </div>
@@ -552,7 +539,7 @@ watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]
step="0.000001" step="0.000001"
class="form-control form-control-sm" class="form-control form-control-sm"
:class="{ 'is-invalid': errors.location }" :class="{ 'is-invalid': errors.location }"
placeholder="Longitudine (Bari)" placeholder="Longitudine (es. 10.2)"
required required
/> />
</div> </div>
@@ -672,7 +659,8 @@ watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]
.map-container { .map-container {
height: 300px; height: 300px;
width: 100%; width: 100%;
z-index: 1; /* Fix for Bootstrap elements overlapping map */ z-index: 1;
overflow: hidden;
} }
.address-suggestions { .address-suggestions {
@@ -694,4 +682,3 @@ textarea {
align-items: center; align-items: center;
} }
</style> </style>
+7 -3
View File
@@ -9,6 +9,7 @@ interface Props {
actionVariant?: ActionVariant actionVariant?: ActionVariant
actionLabel?: string actionLabel?: string
showAssignee?: boolean showAssignee?: boolean
processingIds?: number[]
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -16,6 +17,7 @@ const props = withDefaults(defineProps<Props>(), {
actionVariant: 'open', actionVariant: 'open',
actionLabel: 'Open report', actionLabel: 'Open report',
showAssignee: false, showAssignee: false,
processingIds: () => [],
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -53,7 +55,7 @@ const reportHref = (report: Report) => `${props.detailBasePath}/${report.id}`
<td class="report-table-id">#{{ report.id }}</td> <td class="report-table-id">#{{ report.id }}</td>
<td class="text-nowrap">{{ report.category_name || '-' }}</td> <td class="text-nowrap">{{ report.category_name || '-' }}</td>
<td> <td>
<span class="report-address" :title="report.address"> <span class="report-address d-inline-block text-truncate align-middle" style="max-width: 10ch;" :title="report.address">
{{ report.address || '-' }} {{ report.address || '-' }}
</span> </span>
</td> </td>
@@ -69,18 +71,20 @@ const reportHref = (report: Report) => `${props.detailBasePath}/${report.id}`
v-if="actionVariant === 'manage'" v-if="actionVariant === 'manage'"
type="button" type="button"
class="btn btn-primary btn-xs" class="btn btn-primary btn-xs"
:disabled="processingIds.includes(report.id)"
@click="emit('take', report.id)" @click="emit('take', report.id)"
> >
<span v-if="processingIds.includes(report.id)" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
Prendi in carico Prendi in carico
</button> </button>
<NuxtLink <NuxtLink
v-else v-else
:to="reportHref(report)" :to="reportHref(report)"
class="btn btn-outline-primary btn-xs btn-icon" class="btn btn-outline-primary btn-xs"
:aria-label="actionLabel" :aria-label="actionLabel"
:title="actionLabel" :title="actionLabel"
> >
<BIIcon name="it-arrow-right" size="xs" /> {{ actionLabel }}
</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: 'Chiusa', icon: 'it-locked' } CLOSED: { color: 'secondary', text: 'Conclusa', 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' }
+32 -51
View File
@@ -2,67 +2,48 @@
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="callout callout-highlight callout-danger mb-4"> <div class="card card-bg">
<div class="callout-title">Pannello Admin</div> <div class="card-body">
<p>Accesso di livello 1. Da qui puoi gestire le configurazioni di sistema, le categorie e moderare le segnalazioni.</p> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 card-title mb-0">Gestione sistema</h2>
</div> </div>
<!-- Statistiche Globali (Mock o Reali) --> <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> </NuxtLink>
</div> </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>
<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>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.admin-action {
min-height: 44px;
text-align: left;
}
</style>
+6 -14
View File
@@ -1,15 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
const { getOpenReports, getAssignedReports, takeReport } = useOperator() const { getOpenReports, getAssignedReports } = useOperator()
const { data: openReportsRes, refresh: refreshOpen } = useAsyncData('open-reports', () => getOpenReports({ per_page: 5 })) const { data: openReportsRes } = useAsyncData('open-reports', () => getOpenReports({ per_page: 5 }))
const { data: assignedReportsRes, refresh: refreshAssigned } = useAsyncData('assigned-reports', () => getAssignedReports({ per_page: 5 })) const { data: assignedReportsRes } = useAsyncData('assigned-reports', () => getAssignedReports({ per_page: 5 }))
const handleTakeReport = async (id: number) => {
const res = await takeReport(id)
if (res.ok) {
await Promise.all([refreshOpen(), refreshAssigned()])
}
}
</script> </script>
<template> <template>
@@ -18,8 +11,8 @@ const handleTakeReport = async (id: number) => {
<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">Da prendere in carico</h3> <h3 class="h5 mb-0">Segnalazioni disponibili</h3>
<NuxtLink to="/operator" class="btn btn-link btn-sm pe-0">Apri coda</NuxtLink> <NuxtLink to="/operator" class="btn btn-link btn-sm pe-0">Apri elenco</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.
@@ -27,9 +20,8 @@ const handleTakeReport = async (id: number) => {
<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"
@take="handleTakeReport" action-label="Visualizza"
/> />
</div> </div>
</div> </div>
+35 -6
View File
@@ -1,9 +1,18 @@
import type { import type {
AreaListResponse, CategoryListResponse, CreateAreaRequest, CreateCategoryRequest, CreateTeamRequest, AreaListResponse,
CategoryListResponse,
CreateAreaRequest,
CreateCategoryRequest,
CreateTeamRequest,
ModerationListResponse, ModerationListResponse,
OkResponse, OkResponse,
OverviewStatsResponse, TeamListResponse, UpdateModerationRequest, OverviewStatsResponse,
UpdateUserBanRequest, UpdateUserRolesRequest, UpdateAreaRequest,
UpdateCategoryRequest,
UpdateModerationRequest,
UpdateTeamRequest,
UpdateUserBanRequest,
UpdateUserRolesRequest,
UserListResponse UserListResponse
} from "~/types/api"; } from "~/types/api";
@@ -26,18 +35,33 @@ export const useAdmin = () => {
const createArea = (body: CreateAreaRequest) => const createArea = (body: CreateAreaRequest) =>
useApiFetch<OkResponse>('/api/v1/admin/areas', { method: 'POST', body }) useApiFetch<OkResponse>('/api/v1/admin/areas', { method: 'POST', body })
const updateArea = (id: number, body: UpdateAreaRequest) =>
useApiFetch<OkResponse>(`/api/v1/admin/areas/${id}`, { method: 'PATCH', body })
const deleteArea = (id: number) =>
useApiFetch<OkResponse>(`/api/v1/admin/areas/${id}`, { method: 'DELETE' })
const getCategories = () => const getCategories = () =>
useApiFetch<CategoryListResponse>('/api/v1/admin/categories', { method: 'GET' }) useApiFetch<CategoryListResponse>('/api/v1/admin/categories', { method: 'GET' })
const createCategory = (body: CreateCategoryRequest) => const createCategory = (body: CreateCategoryRequest) =>
useApiFetch<OkResponse>('/api/v1/admin/categories', { method: 'POST', body }) useApiFetch<OkResponse>('/api/v1/admin/categories', { method: 'POST', body })
const getTeams = () => const updateCategory = (id: number, body: UpdateCategoryRequest) =>
useApiFetch<TeamListResponse>('/api/v1/admin/teams', { method: 'GET' }) useApiFetch<OkResponse>(`/api/v1/admin/categories/${id}`, { method: 'PATCH', body })
const deleteCategory = (id: number) =>
useApiFetch<OkResponse>(`/api/v1/admin/categories/${id}`, { method: 'DELETE' })
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 })
const updateTeam = (id: number, body: UpdateTeamRequest) =>
useApiFetch<OkResponse>(`/api/v1/admin/teams/${id}`, { method: 'PATCH', body })
const deleteTeam = (id: number) =>
useApiFetch<OkResponse>(`/api/v1/admin/teams/${id}`, { method: 'DELETE' })
const getModerationFlags = (query?: { status?: string; page?: number; per_page?: number }) => const getModerationFlags = (query?: { status?: string; page?: number; per_page?: number }) =>
useApiFetch<ModerationListResponse>('/api/v1/admin/moderation', { method: 'GET', query }) useApiFetch<ModerationListResponse>('/api/v1/admin/moderation', { method: 'GET', query })
@@ -51,10 +75,15 @@ export const useAdmin = () => {
updateUserRoles, updateUserRoles,
getAreas, getAreas,
createArea, createArea,
updateArea,
deleteArea,
getCategories, getCategories,
createCategory, createCategory,
getTeams, updateCategory,
deleteCategory,
createTeam, createTeam,
updateTeam,
deleteTeam,
getModerationFlags, getModerationFlags,
updateModerationFlag, updateModerationFlag,
} }
+51 -16
View File
@@ -1,10 +1,17 @@
import type { import type {
ChangePasswordRequest, CieSessionResponse,
CurrentUserResponse, EmailRequest, NotificationListResponse, CieStartResponse,
CurrentUserResponse,
EmailRequest,
NotificationListResponse,
OkResponse, OkResponse,
OtpRequestResponse, PasswordLoginRequest, RegisterCitizenRequest, OtpRequestResponse,
RegisterCitizenResponse, RewardsResponse, RegisterCitizenRequest,
TokenResponse, VerifyOtpRequest, UpdateProfileRequest RegisterCitizenResponse,
RewardsResponse,
TokenResponse,
UpdateProfileRequest,
VerifyOtpRequest
} from "~/types/api"; } from "~/types/api";
export const useAuth = () => { export const useAuth = () => {
@@ -22,7 +29,10 @@ 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
@@ -32,6 +42,24 @@ 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
@@ -42,11 +70,10 @@ 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
const path = currentPath ?? useNuxtApp().$router.currentRoute.value.path await nuxtApp.runWithContext(() => refreshUnreadNotifications())
const path = currentPath ?? nuxtApp.$router.currentRoute.value.path
if (res.user.must_change_password && path !== '/auth/change-password') { if (redirectOnSuccess && path.startsWith('/auth')) {
await nuxtApp.runWithContext(() => navigateTo('/auth/change-password'))
} else if (redirectOnSuccess && path.startsWith('/auth')) {
await nuxtApp.runWithContext(() => navigateTo('/dashboard')) await nuxtApp.runWithContext(() => navigateTo('/dashboard'))
} }
} }
@@ -66,13 +93,15 @@ 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({ redirectOnSuccess: true }) await syncUser()
window.location.reload();
} }
const logout = () => { const logout = () => {
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'))
} }
@@ -85,11 +114,14 @@ export const useAuth = () => {
const verifyOtp = (body: VerifyOtpRequest) => const verifyOtp = (body: VerifyOtpRequest) =>
useApiFetch<TokenResponse>('/api/v1/auth/otp/verify', { method: 'POST', body }) useApiFetch<TokenResponse>('/api/v1/auth/otp/verify', { method: 'POST', body })
const loginWithPassword = (body: PasswordLoginRequest) => const startCieLogin = (frontendRedirectUri: string) =>
useApiFetch<TokenResponse>('/api/v1/auth/login/password', { method: 'POST', body }) useApiFetch<CieStartResponse>('/api/v1/auth/cie/start', {
method: 'POST',
body: { frontend_redirect_uri: frontendRedirectUri }
})
const changePassword = (body: ChangePasswordRequest) => const fetchCieSession = (id: string) =>
useApiFetch<OkResponse>('/api/v1/auth/change-password', { method: 'POST', body }) useApiFetch<CieSessionResponse>(`/api/v1/auth/cie/session/${id}`, { method: 'GET' })
const updateProfile = (body: UpdateProfileRequest) => const updateProfile = (body: UpdateProfileRequest) =>
useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'PATCH', body }) useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'PATCH', body })
@@ -112,6 +144,9 @@ export const useAuth = () => {
tokenState, tokenState,
isSyncingUser, isSyncingUser,
isAuthenticated, isAuthenticated,
unreadNotifications,
hasUnreadNotifications,
refreshUnreadNotifications,
handleLoginSuccess, handleLoginSuccess,
syncUser, syncUser,
logout, logout,
@@ -119,8 +154,8 @@ export const useAuth = () => {
register, register,
requestOtp, requestOtp,
verifyOtp, verifyOtp,
loginWithPassword, startCieLogin,
changePassword, fetchCieSession,
updateProfile, updateProfile,
fetchCurrentUser, fetchCurrentUser,
fetchRewards, fetchRewards,
+3 -3
View File
@@ -7,9 +7,9 @@ export interface Member {
export function useMembers() { export function useMembers() {
const members: Member[] = [ const members: Member[] = [
{ name: "Renato Loiacono", image: "https://placehold.co/50x50" }, { name: "Renato Loiacono", image: "/renatoloiacono.webp" },
{ name: "Michelangelo Parisi", image: "https://placehold.co/50x50" }, { name: "Michelangelo Parisi", image: "/michelangeloparisi.webp" },
{ name: "Gabriele Schiraldi", image: "https://placehold.co/50x50" }, { name: "Paolo Gabriele Schiraldi", image: "/paologabrieleschiraldi.webp" },
]; ];
const sortedMembers = computed(() => { const sortedMembers = computed(() => {
+13
View File
@@ -1,4 +1,5 @@
import type { import type {
AssignmentLinkResponse,
AssignTeamRequest, AssignTeamRequest,
InterventionSummaryResponse, InterventionSummaryResponse,
OkResponse, OkResponse,
@@ -32,6 +33,15 @@ export const useOperator = () => {
const getInterventionSummary = (id: number) => const getInterventionSummary = (id: number) =>
useApiFetch<InterventionSummaryResponse>(`/api/v1/reports/${id}/intervention-summary`, { method: 'GET' }) useApiFetch<InterventionSummaryResponse>(`/api/v1/reports/${id}/intervention-summary`, { method: 'GET' })
const createInterventionLink = (id: number) =>
useApiFetch<AssignmentLinkResponse>(`/api/v1/reports/${id}/intervention-link`, { method: 'POST' })
const revokeInterventionLink = (id: number) =>
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,
@@ -41,5 +51,8 @@ export const useOperator = () => {
updateReportStatus, updateReportStatus,
assignTeam, assignTeam,
getInterventionSummary, getInterventionSummary,
createInterventionLink,
revokeInterventionLink,
getPublicIntervention,
} }
} }
+14 -4
View File
@@ -1,15 +1,21 @@
import type { import type {
CategoryListResponse, CategoryListResponse,
CreateReportRequest, CreateReportRequest,
CreateReportResponse, FeedbackRequest, FlagRequest, FlagResponse, CreateReportResponse,
FeedbackRequest,
FlagRequest,
FlagResponse,
MediaUploadResponse, MediaUploadResponse,
MessageListResponse, MessageRequest, OkResponse, MessageListResponse,
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 }) => const getReports = (query?: { area_id?: number; category_id?: number; status?: string; page?: number; per_page?: number }) =>
useApiFetch<ReportListResponse>('/api/v1/reports', { method: 'GET', query }) useApiFetch<ReportListResponse>('/api/v1/reports', { method: 'GET', query })
const getCategories = () => const getCategories = () =>
@@ -39,6 +45,9 @@ 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,
@@ -50,5 +59,6 @@ export const useReports = () => {
addFeedback, addFeedback,
flagReport, flagReport,
flagMessage, flagMessage,
getTeamSuggestions,
} }
} }
+14
View File
@@ -11,6 +11,7 @@ import Footer from "~/components/Footer.vue";
<slot /> <slot />
</main> </main>
<div class="italian-flag"></div>
<Footer /> <Footer />
</div> </div>
</template> </template>
@@ -22,6 +23,19 @@ import Footer from "~/components/Footer.vue";
min-height: 100vh; min-height: 100vh;
} }
div.italian-flag {
height: 8px;
width: 100%;
background: linear-gradient(to right, #009246 0%, #009246 33.33%, #F1F2F1 33.33%, #F1F2F1 66.66%, #CE2B37 66.66%, #CE2B37 100%);
}
div.italian-flag {
height: 8px;
width: 100%;
box-shadow: rgb(0 0 0 / 0.7) 0 0 30px;
background: linear-gradient(to right, #009246 0%, #009246 33.33%, #F1F2F1 33.33%, #F1F2F1 66.66%, #CE2B37 66.66%, #CE2B37 100%);
}
.app-main { .app-main {
flex-grow: 1; flex-grow: 1;
} }
+6 -16
View File
@@ -1,7 +1,8 @@
import { checkRoleAccess, type MetaRoleRequirement } from '~/utils/roles'
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuth() const auth = useAuth()
// Se c'è un token ma l'utente non è ancora caricato e non stiamo già sincronizzando, sincronizza
if ((auth.token.value || auth.tokenState.value) && !auth.user.value && !auth.isSyncingUser.value) { if ((auth.token.value || auth.tokenState.value) && !auth.user.value && !auth.isSyncingUser.value) {
try { try {
await auth.syncUser({ currentPath: to.path }) await auth.syncUser({ currentPath: to.path })
@@ -11,28 +12,17 @@ export default defineNuxtRouteMiddleware(async (to) => {
} }
} }
// Se non autenticato, redirige al login
if (!auth.isAuthenticated.value) { if (!auth.isAuthenticated.value) {
return navigateTo('/auth/login') return navigateTo('/auth/login')
} }
// Verifica ruoli se richiesto const requiredRole = to.meta.auth?.role as MetaRoleRequirement | undefined
const requiredRole = to.meta.auth?.role
if (requiredRole && auth.user.value) { if (requiredRole && auth.user.value) {
const userRole = auth.user.value.role.toUpperCase() const userRole = auth.user.value.role
const roleLevels: Record<string, number> = { if (!checkRoleAccess(userRole, requiredRole)) {
'CITIZEN': 1, console.warn(`[Auth] User role '${userRole}' rejected for route requirement:`, requiredRole)
'OPERATOR': 2,
'ADMIN': 3
}
const userLevel = roleLevels[userRole] || 0
const requiredLevel = roleLevels[requiredRole] || 0
if (userLevel < requiredLevel) {
console.warn(`[Auth] User role ${userRole} (level ${userLevel}) insufficient for required ${requiredRole} (level ${requiredLevel})`)
return navigateTo('/') return navigateTo('/')
} }
} }
+304 -28
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, nextTick, onBeforeUnmount } from 'vue' import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari' import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
definePageMeta({ definePageMeta({
@@ -9,23 +9,129 @@ definePageMeta({
useHead({ title: 'Aree - Admin' }) useHead({ title: 'Aree - Admin' })
const { getAreas, createArea } = useAdmin() const { getAreas, createArea, updateArea, deleteArea } = useAdmin()
const { data: areasRes, pending, refresh } = useAsyncData('admin-areas', () => getAreas()) const { data: areasRes, pending, refresh } = useAsyncData('admin-areas', () => getAreas())
const isCreating = ref(false) const isCreating = ref(false)
const editingAreaId = ref<number | null>(null)
const formData = reactive({ const formData = reactive({
name: '', name: '',
description: '', description: '',
geojson: '' geojson: ''
}) })
const jsonError = ref('') const jsonError = ref('')
const actionError = ref('')
const modalError = ref('')
const deletingAreaId = ref<number | null>(null)
const areaPendingDelete = ref<any | null>(null)
// Map variables
let map: any = null let map: any = null
let mainMap: any = null
let polygon: any = null let polygon: any = null
const mapPoints = ref<[number, number][]>([]) const mapPoints = ref<[number, number][]>([])
const modalTitle = computed(() => editingAreaId.value ? 'Modifica Area' : 'Nuova Area')
const submitLabel = computed(() => editingAreaId.value ? 'Salva Modifiche' : 'Crea Area')
const isEditing = computed(() => editingAreaId.value !== null)
const areas = computed(() => areasRes.value?.areas || [])
const areaDeleteMessage = computed(() =>
areaPendingDelete.value ? `Vuoi eliminare area "${areaPendingDelete.value.name}"? Operazione irreversibile.` : ''
)
const initMainMap = async () => {
const mapElement = document.getElementById('mainMap')
if (!mapElement) return
const L = (await import('leaflet')).default
if (mainMap) {
mainMap.remove()
mainMap = null
}
const bariBounds = L.latLngBounds(
[BARI_BOUNDS.south, BARI_BOUNDS.west],
[BARI_BOUNDS.north, BARI_BOUNDS.east]
)
mainMap = L.map('mainMap', {
maxBounds: bariBounds,
maxBoundsViscosity: 1,
minZoom: 11
}).setView(BARI_CENTER, 12)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(mainMap)
const featureGroup = L.featureGroup().addTo(mainMap)
let hasLayers = false
areas.value.forEach((area: any) => {
if (area.boundary_geojson) {
try {
const layer = L.geoJSON(JSON.parse(area.boundary_geojson), {
style: {
color: '#0d6efd',
weight: 2,
fillOpacity: 0.2
}
}).bindPopup(`<strong>${area.name}</strong><br/>${area.description || ''}`)
layer.addTo(featureGroup)
hasLayers = true
} catch (e) {
console.error('Invalid geojson for area', area.id, e)
}
}
})
if (hasLayers) {
mainMap.fitBounds(featureGroup.getBounds())
} else {
mainMap.fitBounds(bariBounds)
}
}
watch([areas, pending], () => {
if (!pending.value) {
if (areas.value.length > 0) {
nextTick(() => {
initMainMap()
})
} else if (mainMap) {
mainMap.remove()
mainMap = null
}
}
}, { immediate: true })
const renderExistingAreasInModal = (L: any, currentMap: any) => {
areas.value.forEach((area: any) => {
if (area.id === editingAreaId.value) return
if (area.boundary_geojson) {
try {
L.geoJSON(JSON.parse(area.boundary_geojson), {
style: {
color: '#6c757d',
weight: 2,
dashArray: '5, 5',
fillOpacity: 0.1
},
interactive: false
}).addTo(currentMap)
} catch (e) {
}
}
})
}
const openCreateModal = () => { const openCreateModal = () => {
actionError.value = ''
modalError.value = ''
editingAreaId.value = null
formData.name = '' formData.name = ''
formData.description = '' formData.description = ''
formData.geojson = '' formData.geojson = ''
@@ -34,7 +140,6 @@ const openCreateModal = () => {
isCreating.value = true isCreating.value = true
nextTick(async () => { nextTick(async () => {
// Import leaflet solo client-side
const L = (await import('leaflet')).default const L = (await import('leaflet')).default
if (map) { if (map) {
@@ -60,6 +165,8 @@ const openCreateModal = () => {
polygon = L.polygon([], { color: 'red' }).addTo(map) polygon = L.polygon([], { color: 'red' }).addTo(map)
map.fitBounds(bariBounds) map.fitBounds(bariBounds)
renderExistingAreasInModal(L, map)
map.on('click', (e: any) => { map.on('click', (e: any) => {
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) { if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.' jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
@@ -67,6 +174,88 @@ const openCreateModal = () => {
} }
jsonError.value = '' jsonError.value = ''
modalError.value = ''
mapPoints.value.push([e.latlng.lat, e.latlng.lng])
polygon.setLatLngs(mapPoints.value)
updateGeojson()
})
})
}
const loadPolygonFromGeoJson = (boundaryGeojson: any) => {
const coordinates = boundaryGeojson?.coordinates?.[0]
if (!Array.isArray(coordinates)) {
mapPoints.value = []
return
}
const normalized = coordinates
.slice(0, -1)
.map(([longitude, latitude]: [number, number]) => [latitude, longitude] as [number, number])
mapPoints.value = normalized
if (polygon) {
polygon.setLatLngs(mapPoints.value)
}
}
const openEditModal = (area: any) => {
actionError.value = ''
modalError.value = ''
editingAreaId.value = area.id
formData.name = area.name || ''
formData.description = area.description || ''
formData.geojson = area.boundary_geojson || ''
jsonError.value = ''
mapPoints.value = []
isCreating.value = true
nextTick(async () => {
const L = (await import('leaflet')).default
if (map) {
map.remove()
map = null
}
const bariBounds = L.latLngBounds(
[BARI_BOUNDS.south, BARI_BOUNDS.west],
[BARI_BOUNDS.north, BARI_BOUNDS.east]
)
map = L.map('areaMap', {
maxBounds: bariBounds,
maxBoundsViscosity: 1,
minZoom: 12
}).setView(BARI_CENTER, 12)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(map)
console.log(JSON.parse(area.boundary_geojson))
L.geoJSON(JSON.parse(area.boundary_geojson), {
style: {
color: '#0d6efd',
weight: 2,
fillOpacity: 0.2
}
}).addTo(map)
polygon = L.polygon([], { color: 'red' }).addTo(map)
map.fitBounds(bariBounds)
renderExistingAreasInModal(L, map)
loadPolygonFromGeoJson(area.boundary_geojson)
map.on('click', (e: any) => {
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
return
}
jsonError.value = ''
modalError.value = ''
mapPoints.value.push([e.latlng.lat, e.latlng.lng]) mapPoints.value.push([e.latlng.lat, e.latlng.lng])
polygon.setLatLngs(mapPoints.value) polygon.setLatLngs(mapPoints.value)
updateGeojson() updateGeojson()
@@ -80,6 +269,7 @@ const clearMap = () => {
polygon.setLatLngs([]) polygon.setLatLngs([])
} }
formData.geojson = '' formData.geojson = ''
modalError.value = ''
} }
const updateGeojson = () => { const updateGeojson = () => {
@@ -87,9 +277,9 @@ const updateGeojson = () => {
formData.geojson = '' formData.geojson = ''
return return
} }
const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number]) // Lng, Lat const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number])
if (coords[0]) { if (coords[0]) {
coords.push([...coords[0]]) // Chiudi poligono coords.push([...coords[0]])
} }
formData.geojson = JSON.stringify({ formData.geojson = JSON.stringify({
@@ -100,6 +290,7 @@ const updateGeojson = () => {
const closeCreateModal = () => { const closeCreateModal = () => {
isCreating.value = false isCreating.value = false
editingAreaId.value = null
if (map) { if (map) {
map.remove() map.remove()
map = null map = null
@@ -108,10 +299,12 @@ const closeCreateModal = () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (map) map.remove() if (map) map.remove()
if (mainMap) mainMap.remove()
}) })
const handleCreate = async () => { const handleCreate = async () => {
jsonError.value = '' jsonError.value = ''
modalError.value = ''
let parsedGeojson = null let parsedGeojson = null
try { try {
@@ -131,19 +324,65 @@ const handleCreate = async () => {
} }
try { try {
const res = await createArea({ const payload = {
name: formData.name, name: formData.name,
description: formData.description || undefined, description: formData.description || undefined,
boundary_geojson: parsedGeojson boundary_geojson: parsedGeojson
}) }
const res = editingAreaId.value
? await updateArea(editingAreaId.value, payload)
: await createArea(payload)
if (res.ok) { if (res.ok) {
await refresh() await refresh()
closeCreateModal() closeCreateModal()
actionError.value = ''
} }
} catch (err) { } catch (err: any) {
console.error('Failed to create area', err) console.error('Failed to save area', err)
alert('Errore durante la creazione 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.";
}
}
}
const askDeleteArea = (area: any) => {
areaPendingDelete.value = area
}
const closeDeleteAreaModal = () => {
if (deletingAreaId.value) return
areaPendingDelete.value = null
}
const handleDeleteArea = async () => {
if (!areaPendingDelete.value) return
const area = areaPendingDelete.value
deletingAreaId.value = area.id
actionError.value = ''
try {
const res = await deleteArea(area.id)
if (res.ok) {
if (editingAreaId.value === area.id) {
closeCreateModal()
}
await refresh()
areaPendingDelete.value = null
}
} catch (err: any) {
console.error('Failed to delete area', err)
actionError.value = err?.data?.message || 'Errore durante eliminazione area.'
} finally {
deletingAreaId.value = null
} }
} }
</script> </script>
@@ -155,7 +394,7 @@ const handleCreate = 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 name="it-plus" size="xs" class="me-1" /> <BIIcon class="me-1" name="it-plus" size="xs"/>
Nuova Area Nuova Area
</button> </button>
</div> </div>
@@ -165,10 +404,19 @@ const handleCreate = async () => {
<div class="spinner-border text-primary" role="status"></div> <div class="spinner-border text-primary" role="status"></div>
</div> </div>
<div v-else-if="!areasRes?.areas || areasRes.areas.length === 0" class="alert alert-info"> <div v-else-if="areas.length === 0" class="alert alert-info">
Nessuna area configurata. Nessuna area configurata.
</div> </div>
<template v-else>
<div class="card mb-4 shadow-sm border-0">
<div id="mainMap" class="rounded" style="height: 400px; width: 100%; z-index: 1;"></div>
</div>
<div v-if="actionError" class="alert alert-danger">
{{ actionError }}
</div>
<div v-else class="table-responsive"> <div v-else class="table-responsive">
<table class="table table-hover align-middle bg-white shadow-sm rounded"> <table class="table table-hover align-middle bg-white shadow-sm rounded">
<thead class="table-light"> <thead class="table-light">
@@ -176,44 +424,59 @@ const handleCreate = async () => {
<th>ID</th> <th>ID</th>
<th>Nome</th> <th>Nome</th>
<th>Descrizione</th> <th>Descrizione</th>
<th>Stato</th>
<th>GeoJSON</th> <th>GeoJSON</th>
<th class="text-end">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="area in areasRes.areas" :key="area.id"> <tr v-for="area in areas" :key="area.id">
<td>{{ area.id }}</td> <td>{{ area.id }}</td>
<td><strong>{{ area.name }}</strong></td> <td><strong>{{ area.name }}</strong></td>
<td>{{ area.description || '-' }}</td> <td>{{ area.description || '-' }}</td>
<td>
<span v-if="area.active" class="badge bg-success">Attiva</span>
<span v-else class="badge bg-secondary">Inattiva</span>
</td>
<td> <td>
<span v-if="area.boundary_geojson" class="badge border border-primary text-primary">Configurato</span> <span v-if="area.boundary_geojson" class="badge border border-primary text-primary">Configurato</span>
<span v-else class="badge border border-danger text-danger">Mancante</span> <span v-else class="badge border border-danger text-danger">Mancante</span>
</td> </td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary me-2" @click="openEditModal(area)">
Modifica
</button>
<button
class="btn btn-sm btn-outline-danger ms-2"
:disabled="deletingAreaId === area.id"
@click="askDeleteArea(area)"
>
<span v-if="deletingAreaId === area.id" class="spinner-border spinner-border-sm me-1"></span>
Elimina
</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</template>
<!-- Modale Creazione --> <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">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Nuova Area</h5> <h5 class="modal-title">{{ modalTitle }}</h5>
<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 type="text" class="form-control" v-model="formData.name" required placeholder="Es. Centro Storico" /> <input v-model="formData.name" class="form-control" placeholder="Es. Centro Storico" required
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>
@@ -241,7 +504,8 @@ const handleCreate = 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 solo punti nel Comune di Bari. Clicca sulla mappa per aggiungere vertici al poligono. Sono necessari almeno 3 punti. Sono ammessi
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>
@@ -250,22 +514,34 @@ const handleCreate = 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 type="button" class="btn btn-primary" :disabled="!formData.name || !formData.geojson" @click="handleCreate"> <button :disabled="!formData.name || !formData.geojson" class="btn btn-primary" type="button"
Crea Area @click="handleCreate">
{{ submitLabel }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<ConfirmModal
:open="areaPendingDelete !== null"
title="Elimina area"
:message="areaDeleteMessage"
confirm-label="Elimina area"
confirm-class="btn-danger"
:pending="deletingAreaId !== null"
@cancel="closeDeleteAreaModal"
@confirm="handleDeleteArea"
/>
</div> </div>
</template> </template>
<style scoped> <style scoped>
#areaMap { #areaMap, #mainMap {
background: #e9ecef; background: #e9ecef;
} }
.modal-dialog { .modal-dialog {
margin-block: 0!important; margin-block: 0 !important;
} }
</style> </style>
+263 -22
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Category, Team } from '~/types/api'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
auth: { role: 'ADMIN' } auth: { role: 'ADMIN' }
@@ -6,10 +8,14 @@ definePageMeta({
useHead({ title: 'Categorie e Team - Admin' }) useHead({ title: 'Categorie e Team - Admin' })
const { createCategory, getTeams, createTeam, getAreas } = useAdmin() const { createCategory, updateCategory, deleteCategory, createTeam, updateTeam, deleteTeam, getAreas } = useAdmin()
const { getCategories } = useReports() const { getCategories } = useReports()
const { getTeams } = useOperator()
const { data: categoriesRes, refresh: refreshCategories } = await useAsyncData('report-categories-admin-view', () => getCategories()) const {
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())
@@ -18,6 +24,8 @@ const categoryForm = reactive({
description: '', description: '',
}) })
const editingCategoryId = ref<number | null>(null)
const teamForm = reactive({ const teamForm = reactive({
name: '', name: '',
description: '', description: '',
@@ -25,26 +33,70 @@ const teamForm = reactive({
area_id: null as number | null, area_id: null as number | null,
}) })
const editingTeamId = ref<number | null>(null)
const isSavingCategory = ref(false) const isSavingCategory = ref(false)
const isSavingTeam = ref(false) const isSavingTeam = ref(false)
const categoryError = ref('') const categoryError = ref('')
const teamError = ref('') const teamError = ref('')
const deletingCategoryId = ref<number | null>(null)
const deletingTeamId = ref<number | null>(null)
const categoryPendingDelete = ref<Category | null>(null)
const teamPendingDelete = ref<Team | null>(null)
const categories = computed(() => categoriesRes.value?.categories || []) const categories = computed(() => categoriesRes.value?.categories || [])
const teams = computed(() => teamsRes.value?.teams || []) const teams = computed(() => teamsRes.value?.teams || [])
const areas = computed(() => areasRes.value?.areas || []) const areas = computed(() => areasRes.value?.areas || [])
const categoryDeleteMessage = computed(() =>
categoryPendingDelete.value ? `Vuoi eliminare categoria "${categoryPendingDelete.value.name}"? Operazione irreversibile.` : ''
)
const teamDeleteMessage = computed(() =>
teamPendingDelete.value ? `Vuoi eliminare team "${teamPendingDelete.value.name}"? Operazione irreversibile.` : ''
)
const resetCategoryForm = () => {
editingCategoryId.value = null
categoryForm.name = ''
categoryForm.description = ''
}
const resetTeamForm = () => {
editingTeamId.value = null
teamForm.name = ''
teamForm.description = ''
teamForm.team_category_id = null
teamForm.area_id = null
}
const editCategory = (category: Category) => {
editingCategoryId.value = category.id
categoryForm.name = category.name
categoryForm.description = category.description || ''
}
const editTeam = (team: Team) => {
editingTeamId.value = team.id
teamForm.name = team.name
teamForm.description = team.description || ''
teamForm.team_category_id = team.team_category_id
teamForm.area_id = team.area_id ?? null
}
const submitCategory = async () => { const submitCategory = async () => {
categoryError.value = '' categoryError.value = ''
isSavingCategory.value = true isSavingCategory.value = true
try { try {
await createCategory({ const payload = {
name: categoryForm.name.trim(), name: categoryForm.name.trim(),
description: categoryForm.description.trim() || undefined, description: categoryForm.description.trim() || undefined,
}) }
categoryForm.name = '' if (editingCategoryId.value) {
categoryForm.description = '' await updateCategory(editingCategoryId.value, payload)
} else {
await createCategory(payload)
}
resetCategoryForm()
await refreshCategories() await refreshCategories()
} catch (error: any) { } catch (error: any) {
categoryError.value = error?.data?.message || 'Creazione categoria non riuscita.' categoryError.value = error?.data?.message || 'Creazione categoria non riuscita.'
@@ -60,16 +112,18 @@ const submitTeam = async () => {
isSavingTeam.value = true isSavingTeam.value = true
try { try {
await createTeam({ const payload = {
name: teamForm.name.trim(), name: teamForm.name.trim(),
description: teamForm.description.trim() || undefined, description: teamForm.description.trim() || undefined,
team_category_id: teamForm.team_category_id, team_category_id: teamForm.team_category_id,
area_id: teamForm.area_id || undefined, area_id: teamForm.area_id || undefined,
}) }
teamForm.name = '' if (editingTeamId.value) {
teamForm.description = '' await updateTeam(editingTeamId.value, payload)
teamForm.team_category_id = null } else {
teamForm.area_id = null await createTeam(payload)
}
resetTeamForm()
await refreshTeams() await refreshTeams()
} catch (error: any) { } catch (error: any) {
teamError.value = error?.data?.message || 'Creazione team non riuscita.' teamError.value = error?.data?.message || 'Creazione team non riuscita.'
@@ -77,6 +131,86 @@ const submitTeam = async () => {
isSavingTeam.value = false isSavingTeam.value = false
} }
} }
const toggleCategoryActive = async (category: Category) => {
try {
await updateCategory(category.id, { active: category.active === false })
await refreshCategories()
} catch (error: any) {
categoryError.value = error?.data?.message || 'Aggiornamento categoria non riuscito.'
}
}
const askDeleteCategory = (category: Category) => {
categoryPendingDelete.value = category
}
const closeDeleteCategoryModal = () => {
if (deletingCategoryId.value) return
categoryPendingDelete.value = null
}
const handleDeleteCategory = async () => {
if (!categoryPendingDelete.value) return
const category = categoryPendingDelete.value
deletingCategoryId.value = category.id
categoryError.value = ''
try {
await deleteCategory(category.id)
if (editingCategoryId.value === category.id) {
resetCategoryForm()
}
await refreshCategories()
categoryPendingDelete.value = null
} catch (error: any) {
categoryError.value = error?.data?.message || 'Eliminazione categoria non riuscita.'
} finally {
deletingCategoryId.value = null
}
}
const toggleTeamActive = async (team: Team) => {
try {
await updateTeam(team.id, { active: team.active === false })
await refreshTeams()
} catch (error: any) {
teamError.value = error?.data?.message || 'Aggiornamento team non riuscito.'
}
}
const askDeleteTeam = (team: Team) => {
teamPendingDelete.value = team
}
const closeDeleteTeamModal = () => {
if (deletingTeamId.value) return
teamPendingDelete.value = null
}
const handleDeleteTeam = async () => {
if (!teamPendingDelete.value) return
const team = teamPendingDelete.value
deletingTeamId.value = team.id
teamError.value = ''
try {
await deleteTeam(team.id)
if (editingTeamId.value === team.id) {
resetTeamForm()
}
await refreshTeams()
teamPendingDelete.value = null
} catch (error: any) {
teamError.value = error?.data?.message || 'Eliminazione team non riuscita.'
} finally {
deletingTeamId.value = null
}
}
</script> </script>
<template> <template>
@@ -105,7 +239,7 @@ const submitTeam = 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" type="text" class="form-control" required /> <input v-model="categoryForm.name" class="form-control" required type="text"/>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Descrizione</label> <label class="form-label">Descrizione</label>
@@ -113,17 +247,26 @@ const submitTeam = async () => {
</div> </div>
<button type="submit" class="btn btn-primary" :disabled="isSavingCategory || !categoryForm.name.trim()"> <button type="submit" class="btn btn-primary" :disabled="isSavingCategory || !categoryForm.name.trim()">
<span v-if="isSavingCategory" class="spinner-border spinner-border-sm me-2"></span> <span v-if="isSavingCategory" class="spinner-border spinner-border-sm me-2"></span>
Crea categoria {{ editingCategoryId ? 'Salva categoria' : 'Crea categoria' }}
</button>
<button
v-if="editingCategoryId"
class="btn btn-outline-secondary ms-2"
type="button"
@click="resetCategoryForm"
>
Annulla
</button> </button>
</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"> <table class="table table-hover report-table mb-0 align-middle">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Nome</th> <th>Nome</th>
<th>Stato</th> <th>Stato</th>
<th class="text-end">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -138,9 +281,33 @@ const submitTeam = async () => {
{{ category.active === false ? 'Inattiva' : 'Attiva' }} {{ category.active === false ? 'Inattiva' : 'Attiva' }}
</span> </span>
</td> </td>
<td class="text-end">
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-outline-primary btn-icon" title="Modifica" @click="editCategory(category)">
<Icon name="lucide:pencil" size="16"/>
</button>
<button
:class="category.active === false ? 'btn-outline-success' : 'btn-outline-secondary'"
:title="category.active === false ? 'Attiva' : 'Disattiva'"
class="btn btn-icon"
@click="toggleCategoryActive(category)"
>
<Icon :name="category.active === false ? 'lucide:check-circle' : 'lucide:ban'" size="16"/>
</button>
<button
:disabled="deletingCategoryId === category.id"
class="btn btn-outline-danger btn-icon"
title="Elimina"
@click="askDeleteCategory(category)"
>
<span v-if="deletingCategoryId === category.id" class="spinner-border spinner-icon"></span>
<Icon v-else name="lucide:trash-2" size="16"/>
</button>
</div>
</td>
</tr> </tr>
<tr v-if="categories.length === 0"> <tr v-if="categories.length === 0">
<td colspan="3" class="text-muted">Nessuna categoria configurata.</td> <td class="text-muted text-center py-4" colspan="4">Nessuna categoria configurata.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -164,7 +331,7 @@ const submitTeam = 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" type="text" class="form-control" required /> <input v-model="teamForm.name" class="form-control" required type="text"/>
</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>
@@ -186,18 +353,27 @@ const submitTeam = 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" type="text" class="form-control" /> <input v-model="teamForm.description" class="form-control" type="text"/>
</div> </div>
<div class="col-12"> <div class="col-12">
<button type="submit" class="btn btn-primary" :disabled="isSavingTeam || !teamForm.name.trim() || !teamForm.team_category_id"> <button :disabled="isSavingTeam || !teamForm.name.trim() || !teamForm.team_category_id" class="btn btn-primary"
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>
Crea team {{ editingTeamId ? 'Salva team' : 'Crea team' }}
</button>
<button
v-if="editingTeamId"
class="btn btn-outline-secondary ms-2"
type="button"
@click="resetTeamForm"
>
Annulla
</button> </button>
</div> </div>
</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"> <table class="table table-hover report-table mb-0 align-middle">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -205,6 +381,7 @@ const submitTeam = async () => {
<th>Categoria</th> <th>Categoria</th>
<th>Area</th> <th>Area</th>
<th>Stato</th> <th>Stato</th>
<th class="text-end">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -221,9 +398,33 @@ const submitTeam = async () => {
{{ team.active === false ? 'Inattivo' : 'Attivo' }} {{ team.active === false ? 'Inattivo' : 'Attivo' }}
</span> </span>
</td> </td>
<td class="text-end">
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-outline-primary btn-icon" title="Modifica" @click="editTeam(team)">
<Icon name="lucide:pencil" size="16"/>
</button>
<button
:class="team.active === false ? 'btn-outline-success' : 'btn-outline-secondary'"
:title="team.active === false ? 'Attiva' : 'Disattiva'"
class="btn btn-icon"
@click="toggleTeamActive(team)"
>
<Icon :name="team.active === false ? 'lucide:check-circle' : 'lucide:ban'" size="16"/>
</button>
<button
:disabled="deletingTeamId === team.id"
class="btn btn-outline-danger btn-icon"
title="Elimina"
@click="askDeleteTeam(team)"
>
<span v-if="deletingTeamId === team.id" class="spinner-border spinner-icon"></span>
<Icon v-else name="lucide:trash-2" size="16"/>
</button>
</div>
</td>
</tr> </tr>
<tr v-if="teams.length === 0"> <tr v-if="teams.length === 0">
<td colspan="5" class="text-muted">Nessun team configurato.</td> <td class="text-muted text-center py-4" colspan="6">Nessun team configurato.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -232,5 +433,45 @@ const submitTeam = async () => {
</div> </div>
</div> </div>
</div> </div>
<ConfirmModal
:message="categoryDeleteMessage"
:open="categoryPendingDelete !== null"
:pending="deletingCategoryId !== null"
confirm-class="btn-danger"
confirm-label="Elimina categoria"
title="Elimina categoria"
@cancel="closeDeleteCategoryModal"
@confirm="handleDeleteCategory"
/>
<ConfirmModal
:message="teamDeleteMessage"
:open="teamPendingDelete !== null"
:pending="deletingTeamId !== null"
confirm-class="btn-danger"
confirm-label="Elimina team"
title="Elimina team"
@cancel="closeDeleteTeamModal"
@confirm="handleDeleteTeam"
/>
</div> </div>
</template> </template>
<style scoped>
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.spinner-icon {
width: 14px;
height: 14px;
border-width: 2px;
}
</style>
+165 -28
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { reactive, ref } from 'vue'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
@@ -13,6 +13,19 @@ const { getModerationFlags, updateModerationFlag } = useAdmin()
const page = ref(1) const page = ref(1)
const perPage = ref(10) const perPage = ref(10)
const statusFilter = ref('') const statusFilter = ref('')
const actionError = ref('')
const resolveModal = reactive({
open: false,
flagId: null as number | null,
rejectReport: false,
note: '',
pending: false,
})
const dismissModal = reactive({
open: false,
flagId: null as number | null,
pending: false,
})
const { data: flagsRes, pending, refresh } = useAsyncData( const { data: flagsRes, pending, refresh } = useAsyncData(
'admin-moderation-flags', 'admin-moderation-flags',
@@ -20,36 +33,72 @@ const { data: flagsRes, pending, refresh } = useAsyncData(
{ watch: [page, statusFilter] } { watch: [page, statusFilter] }
) )
const handleResolve = async (id: number, rejectReport: boolean) => { const openResolveModal = (id: number, rejectReport: boolean) => {
const note = prompt('Inserisci una nota di risoluzione (opzionale):') resolveModal.open = true
if (note === null) return // cancelled resolveModal.flagId = id
resolveModal.rejectReport = rejectReport
resolveModal.note = ''
}
const closeResolveModal = () => {
if (resolveModal.pending) return
resolveModal.open = false
resolveModal.flagId = null
resolveModal.note = ''
}
const handleResolve = async () => {
if (!resolveModal.flagId) return
resolveModal.pending = true
actionError.value = ''
try { try {
const res = await updateModerationFlag(id, { await updateModerationFlag(resolveModal.flagId, {
status: 'RESOLVED', status: 'RESOLVED',
reject_report: rejectReport, reject_report: resolveModal.rejectReport,
resolution_note: note resolution_note: resolveModal.note || undefined
}) })
if (res.ok) {
resolveModal.pending = false
await refresh() await refresh()
} closeResolveModal()
} catch (err) { } catch (err) {
console.error('Failed to resolve flag', err) console.error('Failed to resolve flag', err)
alert('Errore durante la risoluzione della segnalazione.') actionError.value = 'Errore durante la risoluzione della segnalazione.'
} finally {
resolveModal.pending = false
} }
} }
const handleDismiss = async (id: number) => { const openDismissModal = (id: number) => {
if (!confirm('Sei sicuro di voler ignorare (dismiss) questo flag?')) return dismissModal.open = true
dismissModal.flagId = id
}
const closeDismissModal = () => {
if (dismissModal.pending) return
dismissModal.open = false
dismissModal.flagId = null
}
const handleDismiss = async () => {
if (!dismissModal.flagId) return
dismissModal.pending = true
actionError.value = ''
try { try {
const res = await updateModerationFlag(id, { status: 'DISMISSED' }) await updateModerationFlag(dismissModal.flagId, { status: 'DISMISSED' })
if (res.ok) {
dismissModal.pending = false
await refresh() await refresh()
} closeDismissModal()
} catch (err) { } catch (err) {
console.error('Failed to dismiss flag', err) console.error('Failed to dismiss flag', err)
alert('Errore durante l\'aggiornamento.') actionError.value = 'Errore durante l\'aggiornamento.'
} finally {
dismissModal.pending = false
} }
} }
@@ -59,6 +108,19 @@ 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>
@@ -80,24 +142,33 @@ const formatDate = (dateStr: string) => {
</div> </div>
</div> </div>
<div v-if="actionError" class="alert alert-danger mb-4">
{{ actionError }}
</div>
<div v-if="pending" class="text-center py-5"> <div v-if="pending" class="text-center py-5">
<div class="spinner-border text-danger" role="status"></div> <div class="spinner-border text-danger" role="status"></div>
</div> </div>
<div v-else-if="!flagsRes?.flags || flagsRes.flags.length === 0" class="alert alert-success"> <div v-else-if="!flagsRes || !flagsRes.flags || flagsRes.flags.length === 0" class="alert alert-success">
Nessun elemento da moderare al momento. Nessun elemento da moderare al momento.
</div> </div>
<div v-else> <div v-else>
<div class="card mb-3 shadow-sm border-0" v-for="flag in flagsRes.flags" :key="flag.id"> <div
<div class="card-header bg-light d-flex justify-content-between align-items-center"> class="card mb-3 border-0"
:class="flag.status === 'DISMISSED' ? 'dismissed-card shadow-none' : 'shadow-sm'"
v-for="flag in flagsRes.flags"
:key="flag.id"
>
<div class="card-header d-flex justify-content-between align-items-center" :class="flag.status === 'DISMISSED' ? 'bg-transparent' : 'bg-light'">
<div> <div>
<strong>Flag #{{ flag.id }}</strong> - <strong>Flag #{{ flag.id }}</strong> -
<span class="badge" :class="{ <span class="badge" :class="{
'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'
}">{{ flag.status }}</span> }">{{ formatFlagStatus(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>
@@ -109,8 +180,8 @@ const formatDate = (dateStr: string) => {
{{ flag.details || 'Nessun dettaglio aggiuntivo fornito.' }} {{ flag.details || 'Nessun dettaglio aggiuntivo fornito.' }}
</p> </p>
<div class="mt-3"> <div class="mt-info">
<span class="badge bg-info me-2">{{ flag.content_type }}</span> <span class="badge bg-info me-2">{{ contentTypeLabel(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">
@@ -122,7 +193,7 @@ const formatDate = (dateStr: string) => {
</span> </span>
</div> </div>
<div class="mt-3 small"> <div class="mt-info small text-muted">
Segnalato da: {{ flag.created_by_email }} (ID: {{ flag.created_by_user_id }}) Segnalato da: {{ flag.created_by_email }} (ID: {{ flag.created_by_user_id }})
</div> </div>
@@ -134,13 +205,13 @@ const formatDate = (dateStr: string) => {
<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="handleResolve(flag.id, true)"> <button class="btn btn-outline-danger btn-sm" @click="openResolveModal(flag.id, canRejectReport(flag))">
<BIIcon name="it-ban" size="xs" /> Risolvi & Rigetta Segn. <BIIcon name="it-ban" size="xs" /> {{ canRejectReport(flag) ? 'Risolvi & Rigetta Segn.' : 'Risolvi flag' }}
</button> </button>
<button class="btn btn-outline-warning btn-sm" @click="handleResolve(flag.id, false)"> <button class="btn btn-outline-warning btn-sm" @click="openResolveModal(flag.id, false)">
<BIIcon name="it-check" size="xs" /> Risolvi (Solo Avviso) <BIIcon name="it-check" size="xs" /> {{ canRejectReport(flag) ? 'Risolvi (Solo Avviso)' : 'Risolvi senza rigetto' }}
</button> </button>
<button class="btn btn-outline-secondary btn-sm" @click="handleDismiss(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
</button> </button>
</div> </div>
@@ -157,5 +228,71 @@ const formatDate = (dateStr: string) => {
@page-change="page = $event" @page-change="page = $event"
/> />
</div> </div>
<div v-if="resolveModal.open" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<div v-if="resolveModal.open" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ resolveModal.rejectReport ? 'Risolvi e rigetta segnalazione' : 'Risolvi flag' }}</h5>
<button type="button" class="btn-close" :disabled="resolveModal.pending" @click="closeResolveModal"></button>
</div>
<div class="modal-body">
<label class="form-label">Nota di risoluzione</label>
<textarea v-model="resolveModal.note" class="form-control" rows="4" placeholder="Nota opzionale"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" :disabled="resolveModal.pending" @click="closeResolveModal">Annulla</button>
<button type="button" class="btn btn-primary" :disabled="resolveModal.pending" @click="handleResolve">
<span v-if="resolveModal.pending" class="spinner-border spinner-border-sm me-2"></span>
Conferma
</button>
</div>
</div>
</div>
</div>
<ConfirmModal
:open="dismissModal.open"
title="Ignora flag"
message="Vuoi ignorare questo flag? Azione registrata in moderazione."
confirm-label="Ignora flag"
confirm-class="btn-outline-secondary"
:pending="dismissModal.pending"
@cancel="closeDismissModal"
@confirm="handleDismiss"
/>
</div> </div>
</template> </template>
<style scoped>
.dismissed-card {
background-color: #f8f9fa;
opacity: 0.7;
border: 1px solid #e9ecef !important;
}
.dismissed-card .card-header {
padding-top: 0.35rem;
padding-bottom: 0.35rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.dismissed-card .card-body {
padding-top: 0.6rem;
padding-bottom: 0.6rem;
}
.dismissed-card h5.card-title {
font-size: 1rem;
margin-bottom: 0.2rem;
}
.mt-info {
margin-top: 1rem;
}
.dismissed-card .mt-info {
margin-top: 0.4rem;
}
</style>
+258
View File
@@ -0,0 +1,258 @@
<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>
+54 -8
View File
@@ -13,7 +13,18 @@ const { getUsers, updateUserBan, updateUserRoles } = useAdmin()
const { data: usersRes, pending, refresh } = useAsyncData('admin-users', () => getUsers()) const { data: usersRes, pending, refresh } = useAsyncData('admin-users', () => getUsers())
const search = ref('') const search = ref('')
const parseRoles = (roles?: string) => roles ? roles.split(',').map((r) => r.trim()).filter(Boolean) : [] const banError = ref('')
const roleError = ref('')
const pendingBanUser = ref<any | null>(null)
const isUpdatingBan = ref(false)
const parseRoles = (roles?: string) => roles ? roles.split(',').map(normalizeRole).filter(Boolean) : []
const banConfirmMessage = computed(() =>
pendingBanUser.value
? `Vuoi ${pendingBanUser.value.banned ? 'sbloccare' : 'bannare'} utente ${pendingBanUser.value.first_name} ${pendingBanUser.value.last_name}?`
: ''
)
const banConfirmLabel = computed(() => pendingBanUser.value?.banned ? 'Sblocca utente' : 'Banna utente')
const banConfirmClass = computed(() => pendingBanUser.value?.banned ? 'btn-success' : 'btn-danger')
const filteredUsers = computed(() => { const filteredUsers = computed(() => {
if (!usersRes.value?.users) return [] if (!usersRes.value?.users) return []
@@ -27,21 +38,36 @@ const filteredUsers = computed(() => {
) )
}) })
const handleToggleBan = async (id: number, currentBanStatus: boolean) => { const askToggleBan = (user: any) => {
if (!confirm(`Sei sicuro di voler ${currentBanStatus ? 'sbloccare' : 'bannare'} questo utente?`)) return pendingBanUser.value = user
}
const closeBanModal = () => {
if (isUpdatingBan.value) return
pendingBanUser.value = null
}
const handleToggleBan = async () => {
if (!pendingBanUser.value) return
const user = pendingBanUser.value
isUpdatingBan.value = true
banError.value = ''
try { try {
const res = await updateUserBan(id, { banned: !currentBanStatus }) const res = await updateUserBan(user.id, { banned: !user.banned })
if (res.ok) { if (res.ok) {
await refresh() await refresh()
pendingBanUser.value = null
} }
} catch (err) { } catch (err) {
console.error('Failed to update user ban status', err) console.error('Failed to update user ban status', err)
alert('Errore durante l\'aggiornamento dello stato di ban.') banError.value = 'Errore durante l\'aggiornamento dello stato di ban.'
} finally {
isUpdatingBan.value = false
} }
} }
// Modal per i ruoli
const editingUserId = ref<number | null>(null) const editingUserId = ref<number | null>(null)
const selectedRoles = ref<string[]>([]) const selectedRoles = ref<string[]>([])
const availableRoles = ['CITIZEN', 'OPERATOR', 'ADMIN'] const availableRoles = ['CITIZEN', 'OPERATOR', 'ADMIN']
@@ -59,6 +85,7 @@ const closeRoleModal = () => {
const saveRoles = async () => { const saveRoles = async () => {
if (!editingUserId.value) return if (!editingUserId.value) return
roleError.value = ''
try { try {
const res = await updateUserRoles(editingUserId.value, { roles: selectedRoles.value }) const res = await updateUserRoles(editingUserId.value, { roles: selectedRoles.value })
if (res.ok) { if (res.ok) {
@@ -67,7 +94,7 @@ const saveRoles = async () => {
} }
} catch (err) { } catch (err) {
console.error('Failed to update roles', err) console.error('Failed to update roles', err)
alert('Errore durante l\'aggiornamento dei ruoli.') roleError.value = 'Errore durante l\'aggiornamento dei ruoli.'
} }
} }
</script> </script>
@@ -95,6 +122,14 @@ const saveRoles = async () => {
Nessun utente trovato. Nessun utente trovato.
</div> </div>
<div v-if="banError" class="alert alert-danger">
{{ banError }}
</div>
<div v-if="roleError" class="alert alert-danger">
{{ roleError }}
</div>
<div v-else class="table-responsive"> <div v-else class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
<thead> <thead>
@@ -138,7 +173,7 @@ const saveRoles = async () => {
<button <button
class="btn btn-sm" class="btn btn-sm"
:class="user.banned ? 'btn-outline-success' : 'btn-outline-danger'" :class="user.banned ? 'btn-outline-success' : 'btn-outline-danger'"
@click="handleToggleBan(user.id, !!user.banned)" @click="askToggleBan(user)"
> >
{{ user.banned ? 'Sblocca' : 'Banna' }} {{ user.banned ? 'Sblocca' : 'Banna' }}
</button> </button>
@@ -177,5 +212,16 @@ const saveRoles = async () => {
</div> </div>
</div> </div>
</div> </div>
<ConfirmModal
:open="pendingBanUser !== null"
title="Conferma stato utente"
:message="banConfirmMessage"
:confirm-label="banConfirmLabel"
:confirm-class="banConfirmClass"
:pending="isUpdatingBan"
@cancel="closeBanModal"
@confirm="handleToggleBan"
/>
</div> </div>
</template> </template>
-217
View File
@@ -1,217 +0,0 @@
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
useHead({ title: 'Cambio Password - Visione' })
const { changePassword, user, syncUser } = useAuth()
const router = useRouter()
const formData = reactive({
current_password: '',
new_password: '',
confirm_password: ''
})
const isLoading = ref(false)
const errorMessage = ref('')
const showCurrent = ref(false)
const showNew = ref(false)
const showConfirm = ref(false)
const canSubmit = computed(() => {
return (
formData.current_password.length > 0 &&
formData.new_password.length >= 8 &&
formData.new_password === formData.confirm_password
)
})
const handleSubmit = async () => {
if (!canSubmit.value) return
isLoading.value = true
errorMessage.value = ''
try {
const res = await changePassword({
current_password: formData.current_password,
new_password: formData.new_password
})
if (res.ok) {
await syncUser()
await router.push('/profile')
}
} catch (error: any) {
errorMessage.value = error.data?.message || 'Errore. Verifica la password attuale.'
} finally {
isLoading.value = false
}
}
</script>
<template>
<div class="py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-12 col-lg-8 col-xl-6">
<div v-if="user?.must_change_password" class="callout callout-highlight callout-warning mb-4">
<div class="callout-title d-flex align-items-center">
<BIIcon name="it-warning-circle" size="sm" color="warning" class="me-2" />
Azione richiesta
</div>
<p class="mb-0">Per motivi di sicurezza, imposta una nuova password per il tuo account.</p>
</div>
<div class="card card-bg border-0 shadow-sm">
<div class="card-body p-4 p-lg-5">
<h1 class="h3 mb-3">Cambia password</h1>
<p class="text-muted mb-4">
Aggiorna credenziali prima di continuare. Password nuova deve essere diversa da quella attuale.
</p>
<form @submit.prevent="handleSubmit" class="d-grid gap-4">
<div class="password-field">
<label for="current_password" class="form-label">Password attuale</label>
<div class="password-field-control">
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
<input
id="current_password"
v-model="formData.current_password"
:type="showCurrent ? 'text' : 'password'"
class="form-control"
autocomplete="current-password"
>
<button
type="button"
class="btn btn-password-toggle"
:aria-label="showCurrent ? 'Nascondi password attuale' : 'Mostra password attuale'"
@click="showCurrent = !showCurrent"
>
<BIIcon :name="showCurrent ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button>
</div>
</div>
<div class="password-field">
<label for="new_password" class="form-label">Nuova password</label>
<div class="password-field-control">
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
<input
id="new_password"
v-model="formData.new_password"
:type="showNew ? 'text' : 'password'"
class="form-control"
autocomplete="new-password"
>
<button
type="button"
class="btn btn-password-toggle"
:aria-label="showNew ? 'Nascondi nuova password' : 'Mostra nuova password'"
@click="showNew = !showNew"
>
<BIIcon :name="showNew ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button>
</div>
<div class="form-text">Minimo 8 caratteri.</div>
</div>
<div class="password-field">
<label for="confirm_password" class="form-label">Conferma nuova password</label>
<div class="password-field-control">
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
<input
id="confirm_password"
v-model="formData.confirm_password"
:type="showConfirm ? 'text' : 'password'"
:class="{ 'is-invalid': formData.confirm_password && formData.confirm_password !== formData.new_password }"
class="form-control"
autocomplete="new-password"
>
<button
type="button"
class="btn btn-password-toggle"
:aria-label="showConfirm ? 'Nascondi conferma password' : 'Mostra conferma password'"
@click="showConfirm = !showConfirm"
>
<BIIcon :name="showConfirm ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button>
</div>
</div>
<div v-if="errorMessage" class="alert alert-danger mb-0" role="alert">
{{ errorMessage }}
</div>
<button
type="submit"
class="btn btn-primary w-100 py-2"
:disabled="!canSubmit || isLoading"
>
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
{{ isLoading ? 'Aggiornamento in corso' : 'Aggiorna password' }}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.password-field {
display: grid;
gap: 0.5rem;
}
.password-field-control {
align-items: center;
background: #fff;
border: 1px solid #c5d0db;
border-radius: 8px;
display: grid;
gap: 0.5rem;
grid-template-columns: auto minmax(0, 1fr) auto;
padding-left: 0.875rem;
}
.password-field-control:focus-within {
border-color: #06c;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.16);
}
.password-field-icon {
color: #5c6f82;
}
.password-field-control .form-control {
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
min-width: 0;
padding-left: 0;
padding-right: 0;
}
.btn-password-toggle {
align-items: center;
color: #06c;
display: inline-flex;
height: 100%;
justify-content: center;
min-height: 3rem;
padding: 0 0.875rem;
}
.btn-password-toggle:hover,
.btn-password-toggle:focus {
background: rgba(0, 102, 204, 0.08);
color: #06c;
}
</style>
+153
View File
@@ -0,0 +1,153 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
definePageMeta({
middleware: 'guest'
})
useHead({ title: 'Accesso CIE - Visione' })
const route = useRoute()
const router = useRouter()
const { fetchCieSession, handleLoginSuccess } = useAuth()
const state = computed(() => {
const value = route.query.state
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 errorMessage = ref('')
const statusMessage = ref('Verifica dell’autenticazione CIE in corso...')
let pollTimer: ReturnType<typeof setTimeout> | null = null
let attempts = 0
const maxAttempts = 30
const stopPolling = () => {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
const scheduleNextPoll = () => {
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 () => {
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) {
await redirectToLoginError('invalid_session', 'Sessione CIE non valida: parametro state mancante.')
return
}
attempts += 1
try {
const res = await fetchCieSession(state.value)
if (res.status === 'authenticated' && res.auth?.token) {
stopPolling()
await handleLoginSuccess(res.auth.token)
return
}
if (['failed', 'expired', 'cancelled', 'error'].includes(res.status)) {
await redirectToLoginError('failed', 'Autenticazione CIE non completata. Riprova dal login.')
return
}
if (attempts >= maxAttempts) {
await redirectToLoginError('timeout', 'Tempo di attesa scaduto durante la verifica CIE. Riprova.')
return
}
statusMessage.value = 'Autenticazione completata sul provider. Attendo conferma finale da Visione...'
scheduleNextPoll()
} catch (err: any) {
await redirectToLoginError('verification_error', err.data?.message || 'Errore durante la verifica della sessione CIE.')
}
}
onMounted(() => {
checkSession()
})
onBeforeUnmount(() => {
stopPolling()
})
</script>
<template>
<div class="d-flex align-items-center bg-light py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="card card-bg shadow-sm">
<div class="card-body p-4 p-md-5 text-center">
<BIIcon name="it-card" size="xl" color="primary" class="mb-3" />
<h1 class="h3 font-weight-bold mb-3">Accesso con CIE</h1>
<div v-if="isLoading" class="py-3">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Caricamento...</span>
</div>
<p class="text-muted mb-0">{{ statusMessage }}</p>
</div>
<div v-else-if="errorMessage" class="py-2">
<div class="alert alert-danger text-start mb-4" role="alert">
{{ errorMessage }}
</div>
<button class="btn btn-primary" @click="router.push('/auth/login')">
Torna al login
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
+67 -60
View File
@@ -7,20 +7,35 @@ definePageMeta({
useHead({ title: 'Accedi - Visione' }) useHead({ title: 'Accedi - Visione' })
const { requestOtp, verifyOtp, loginWithPassword, handleLoginSuccess } = useAuth() const runtimeConfig = useRuntimeConfig()
const router = useRouter() const route = useRoute()
const { requestOtp, verifyOtp, handleLoginSuccess, startCieLogin } = useAuth()
// UI State
const loginMethod = ref<'otp' | 'password'>('otp')
const otpStep = ref<1 | 2>(1) const otpStep = ref<1 | 2>(1)
const isLoading = ref(false) const isLoading = ref(false)
const isCieLoading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const email = ref('') const email = ref('')
const password = ref('')
const otpCode = ref('') const otpCode = ref('')
const showPassword = ref(false)
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
@@ -48,18 +63,24 @@ const handleVerifyOtp = async () => {
} }
} }
const handlePasswordLogin = async () => { const handleCieLogin = async () => {
isLoading.value = true isCieLoading.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
const res = await loginWithPassword({ email: email.value, password: password.value }) const res = await startCieLogin(cieCallbackUrl)
await handleLoginSuccess(res.token) await navigateTo(res.authorization_url, {
external: true,
replace: true,
})
} catch (err: any) { } catch (err: any) {
errorMessage.value = 'Credenziali non valide.' errorMessage.value = err.data?.message || 'Avvio autenticazione CIE non riuscito. Riprova.'
} finally { isCieLoading.value = false
isLoading.value = false
} }
} }
const handleOTPInput = async (value: string) => {
console.log(value)
}
</script> </script>
<template> <template>
@@ -79,9 +100,13 @@ const handlePasswordLogin = async () => {
<div class="card-body p-4 p-md-5"> <div class="card-body p-4 p-md-5">
<button class="btn btn-primary w-100 mb-4 d-flex align-items-center justify-content-center gap-2" disabled title="Integrazione CIE prevista per la fase successiva"> <button
<BIIcon name="it-card" size="sm" color="white" /> class="btn btn-primary w-100 mb-4 d-flex align-items-center justify-content-center gap-2 entraconcie"
Entra con CIE (Presto disponibile) :disabled="isCieLoading || isLoading"
@click="handleCieLogin"
>
<span v-if="isCieLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
&#8203;
</button> </button>
<div class="d-flex align-items-center my-4"> <div class="d-flex align-items-center my-4">
@@ -90,12 +115,14 @@ const handlePasswordLogin = async () => {
<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>
<div v-if="loginMethod === 'otp'">
<form v-if="otpStep === 1" @submit.prevent="handleRequestOtp"> <form v-if="otpStep === 1" @submit.prevent="handleRequestOtp">
<div class="form-group mb-4"> <div class="form-group mb-4">
<input type="email" class="form-control" id="email" v-model="email" required> <input type="email" class="form-control" id="email" v-model="email" required>
@@ -103,16 +130,27 @@ const handlePasswordLogin = async () => {
</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>
Ricevi codice OTP Entra
</button> </button>
</form> </form>
<form v-else @submit.prevent="handleVerifyOtp"> <form v-else @submit.prevent="handleVerifyOtp">
<div class="alert alert-success mb-4 small"> <div v-show="!errorMessage" 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 type="text" class="form-control text-center text-tracking-widest fs-4" id="otp" v-model="otpCode" required maxlength="6" autocomplete="one-time-code"> <input
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">
@@ -123,44 +161,6 @@ const handlePasswordLogin = async () => {
<a href="#" class="small text-decoration-none" @click.prevent="otpStep = 1">Modifica indirizzo email</a> <a href="#" class="small text-decoration-none" @click.prevent="otpStep = 1">Modifica indirizzo email</a>
</div> </div>
</form> </form>
</div>
<form v-else @submit.prevent="handlePasswordLogin">
<div class="form-group mb-4">
<input type="email" class="form-control" id="emailPwd" v-model="email" required>
<label for="emailPwd" :class="{ 'active': email.length > 0 }">Indirizzo Email</label>
</div>
<div class="form-group mb-4 position-relative">
<input :type="showPassword ? 'text' : 'password'" class="form-control pe-5" id="password" v-model="password" required>
<label for="password" :class="{ 'active': password.length > 0 }">Password</label>
<button
type="button"
class="btn bg-transparent border-0 shadow-none position-absolute p-2"
style="right: 0; top: 2px; z-index: 10;"
@click="showPassword = !showPassword"
:aria-pressed="showPassword"
aria-label="Mostra/Nascondi password"
>
<BIIcon :name="showPassword ? 'it-password-visible' : 'it-password-invisible'" color="primary" size="sm" />
</button>
</div>
<button type="submit" class="btn btn-primary w-100 mb-3" :disabled="isLoading">
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2" role="status"></span>
Accedi
</button>
</form>
<div class="text-center mt-4">
<a href="#" class="small text-decoration-none" @click.prevent="loginMethod = loginMethod === 'otp' ? 'password' : 'otp'; errorMessage = ''">
{{ loginMethod === 'otp' ? 'Vuoi usare la password? Clicca qui' : 'Usa il login sicuro senza password (OTP)' }}
</a>
</div>
</div> </div>
</div> </div>
@@ -182,4 +182,11 @@ const handlePasswordLogin = async () => {
.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>
+7 -31
View File
@@ -14,14 +14,12 @@ const router = useRouter()
const isLoading = ref(false) const isLoading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const successMessage = ref(false) const successMessage = ref(false)
const showPassword = ref(false)
const form = ref({ const form = ref({
first_name: '', first_name: '',
last_name: '', last_name: '',
fiscal_code: '', fiscal_code: '',
email: '', email: '',
password: '',
phone: '', phone: '',
privacy: false privacy: false
}) })
@@ -56,7 +54,6 @@ const handleRegister = async () => {
last_name: form.value.last_name, last_name: form.value.last_name,
fiscal_code: fiscalCode, fiscal_code: fiscalCode,
email: form.value.email, email: form.value.email,
password: form.value.password,
phone: form.value.phone || undefined phone: form.value.phone || undefined
}) })
@@ -89,7 +86,7 @@ const handleRegister = async () => {
<div class="card-body p-4 p-md-5"> <div class="card-body p-4 p-md-5">
<div v-if="successMessage" class="alert alert-success" role="alert"> <div v-if="successMessage" class="alert alert-success" role="alert">
Registrazione completata con successo! Reindirizzamento al login... Registrazione completata con successo! Reindirizzamento all'accesso con OTP...
</div> </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">
@@ -100,7 +97,6 @@ const handleRegister = async () => {
<h2 class="h5 text-primary font-weight-bold mb-4 border-bottom pb-2">Dati Anagrafici</h2> <h2 class="h5 text-primary font-weight-bold mb-4 border-bottom pb-2">Dati Anagrafici</h2>
<!-- FIX LAYOUT: form-group separato da col -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12 col-md-6 mb-4 mb-md-0"> <div class="col-12 col-md-6 mb-4 mb-md-0">
<div class="form-group"> <div class="form-group">
@@ -122,33 +118,15 @@ const handleRegister = async () => {
<small class="form-text text-muted">Sarà utilizzato come identificativo univoco nel sistema PA.</small> <small class="form-text text-muted">Sarà utilizzato come identificativo univoco nel sistema PA.</small>
</div> </div>
<h2 class="h5 text-primary font-weight-bold mt-5 mb-4 border-bottom pb-2">Credenziali e Contatti</h2> <h2 class="h5 text-primary font-weight-bold mt-5 mb-4 border-bottom pb-2">Contatti</h2>
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12 col-md-6 mb-4 mb-md-0"> <div class="col-12">
<div class="form-group"> <div class="form-group position-relative">
<input type="email" class="form-control" id="regEmail" v-model="form.email" required> <input type="email" class="form-control" id="regEmail" v-model="form.email" required>
<label for="regEmail" :class="{ 'active': form.email.length > 0 }">Indirizzo Email</label> <label for="regEmail" :class="{ 'active': form.email.length > 0 }">Indirizzo Email</label>
</div> </div>
</div> </div>
<div class="col-12 col-md-6">
<!-- INPUT PASSWORD FIXATO -->
<div class="form-group position-relative">
<input :type="showPassword ? 'text' : 'password'" class="form-control pe-5" id="regPwd" v-model="form.password" required minlength="10">
<label for="regPwd" :class="{ 'active': form.password.length > 0 }">Password (min. 10 crt)</label>
<button
type="button"
class="btn bg-transparent border-0 shadow-none position-absolute p-2"
style="right: 0; top: 2px; z-index: 10;"
@click="showPassword = !showPassword"
:aria-pressed="showPassword"
aria-label="Mostra/Nascondi password"
>
<BIIcon :name="showPassword ? 'it-password-visible' : 'it-password-invisible'" color="primary" size="sm" />
</button>
</div>
</div>
</div> </div>
<div class="form-group mb-5"> <div class="form-group mb-5">
@@ -157,7 +135,6 @@ const handleRegister = async () => {
<small class="form-text text-muted">Faciliterà il contatto da parte degli operatori comunali in caso di intervento.</small> <small class="form-text text-muted">Faciliterà il contatto da parte degli operatori comunali in caso di intervento.</small>
</div> </div>
<!-- Privacy Checkbox -->
<div class="form-check mb-4"> <div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="privacyCheck" v-model="form.privacy" required> <input class="form-check-input" type="checkbox" id="privacyCheck" v-model="form.privacy" required>
<label class="form-check-label small" for="privacyCheck"> <label class="form-check-label small" for="privacyCheck">
@@ -169,6 +146,9 @@ const handleRegister = async () => {
<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>
Registra Profilo Registra Profilo
</button> </button>
<p class="small text-muted mt-3 mb-0">
Dopo la registrazione potrai accedere usando OTP via email o CIE, quando disponibile.
</p>
</form> </form>
</div> </div>
@@ -185,8 +165,4 @@ const handleRegister = async () => {
</template> </template>
<style scoped> <style scoped>
/* Previene che il testo finisca sotto l'icona */
.pe-5 {
padding-right: 3rem !important;
}
</style> </style>
+7 -10
View File
@@ -10,24 +10,21 @@ import OperatorDashboard from '~/components/dashboard/OperatorDashboard.vue'
import AdminDashboard from '~/components/dashboard/AdminDashboard.vue' import AdminDashboard from '~/components/dashboard/AdminDashboard.vue'
const currentDashboard = computed(() => { const currentDashboard = computed(() => {
const role = user.value?.role?.toUpperCase() if (isCitizen(user.value?.role)) return CitizenDashboard
if (role === 'CITIZEN') return CitizenDashboard if (isOperator(user.value?.role)) return OperatorDashboard
if (role === 'OPERATOR') return OperatorDashboard if (isAdmin(user.value?.role)) return AdminDashboard
if (role === 'ADMIN') return AdminDashboard
return null return null
}) })
const heroTitle = computed(() => { const heroTitle = computed(() => {
const role = user.value?.role?.toUpperCase() if (isAdmin(user.value?.role)) return 'Pannello Admin'
if (role === 'ADMIN') return 'Pannello Admin' if (isOperator(user.value?.role)) return 'Gestione Operativa'
if (role === 'OPERATOR') return 'Gestione Operativa'
return 'Dashboard' return 'Dashboard'
}) })
const heroSubtitle = computed(() => { const heroSubtitle = computed(() => {
const role = user.value?.role?.toUpperCase() if (isAdmin(user.value?.role)) return 'Accesso amministrativo per configurazioni, utenti e moderazione'
if (role === 'ADMIN') return 'Accesso amministrativo per configurazioni, utenti e moderazione' if (isOperator(user.value?.role)) return 'Prendi in carico e gestisci le segnalazioni in corso'
if (role === 'OPERATOR') return 'Prendi in carico e gestisci le segnalazioni in corso'
return 'Riepilogo rapido delle tue segnalazioni e dei punti cittadino' return 'Riepilogo rapido delle tue segnalazioni e dei punti cittadino'
}) })
</script> </script>
+71 -28
View File
@@ -3,6 +3,8 @@ 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,
@@ -12,6 +14,17 @@ 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>
@@ -23,11 +36,11 @@ const { data: openDataResponse } = await useAsyncData('recent-fixes', () =>
<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 nazionale per segnalare disservizi urbani. Scatta una foto, invia la segnalazione Il portale ufficiale della città metropolitana di Bari 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 class="btn btn-primary" to="/auth/login"> <NuxtLink v-if="!user || isCitizen(user.role)" 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">
@@ -45,25 +58,24 @@ const { data: openDataResponse } = await useAsyncData('recent-fixes', () =>
<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 name="it-camera" size="xl" color="primary" /> <BIIcon color="primary" name="it-camera" size="xl"/>
</div> </div>
<h3 class="h5 font-weight-semibold">1. Segnala</h3> <h3 class="h5 font-weight-semibold">1. Segnala</h3>
<p class="text-muted">Usa lo smartphone per fotografare il problema stradale e inviare la posizione <p class="text-muted">Fotografa il problema stradale e condividi la tua posizione.</p>
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 name="it-tool" size="xl" color="primary" /> <BIIcon color="primary" name="it-tool" size="xl"/>
</div> </div>
<h3 class="h5 font-weight-semibold">2. Intervento</h3> <h3 class="h5 font-weight-semibold">2. Intervento</h3>
<p class="text-muted">Le squadre comunali prendono in carico la tua segnalazione e avviano i lavori.</p> <p class="text-muted">Il Comune attiva subito le squadre operative per risolvere il problema.</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 name="it-star-outline" size="xl" color="primary" /> <BIIcon color="primary" name="it-star-outline" size="xl"/>
</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! Ottieni Punti Cittadino e lascia un feedback sull'intervento.</p> <p class="text-muted">Problema risolto! Guadagna Punti Cittadino e valuta l'intervento.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -96,32 +108,47 @@ const { data: openDataResponse } = await useAsyncData('recent-fixes', () =>
<h2 class="font-weight-bold">Ultimi interventi risolti</h2> <h2 class="font-weight-bold">Ultimi interventi risolti</h2>
<NuxtLink to="/open-data" class="read-more"> <NuxtLink to="/open-data" class="read-more">
<span class="text">Vedi tutti</span> <span class="text">Vedi tutti</span>
<BIIcon name="it-arrow-right" /> <BIIcon name="it-arrow-right"/>
</NuxtLink> </NuxtLink>
</div> </div>
<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"> <div class="card-wrapper card-space h-100">
<div class="card card-bg card-big"> <div class="card card-bg h-100 shadow-sm">
<div class="card-body"> <div class="card-body d-flex flex-column">
<div class="category-top">
<BIIcon name="it-check-circle" size="sm" color="success" />
<span class="category text-success font-weight-semibold">Risolto</span>
</div>
<h3 class="card-title h5 mt-2">{{ report.category_name }}</h3>
<p class="card-text text-muted"> <div class="d-flex justify-content-between align-items-start mb-3">
<span class="me-1"><BIIcon name="it-map-marker-circle" size="xs" /></span> <div class="category-top m-0">
{{ report.area_name || 'Area comunale' }} <span :class="getStatusBadge(report.status).class"
</p> class="badge rounded-pill text-white px-3 py-1">
<BIIcon :name="getStatusBadge(report.status).icon" class="me-1" color="white" size="xs"/>
<div class="it-card-footer mt-4"> {{ getStatusBadge(report.status).text }}
<span class="card-signature">
Aggiornato il {{ new Date(report.updated_at).toLocaleDateString('it-IT') }}
</span> </span>
</div> </div>
</div> </div>
<h3 class="card-title h5 font-weight-bold">{{ report.category_name }}</h3>
<p class="card-text text-muted mb-4">
<BIIcon class="me-1" name="it-map-marker-circle" size="sm"/>
{{ report.area_name || 'Area comunale non specificata' }}
</p>
<div class="mt-auto pt-3 border-top">
<div class="row text-muted small">
<div class="col-6">
<div class="fw-bold text-uppercase">Segnalato</div>
<div>{{ new Date(report.created_at).toLocaleDateString('it-IT') }}</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> </div>
@@ -132,10 +159,10 @@ const { data: openDataResponse } = await useAsyncData('recent-fixes', () =>
</div> </div>
</div> </div>
<div class="container my-5 pb-5"> <div v-show="!user" 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"/>
Partecipa alla cura della tua città Partecipa alla cura della tua città
</div> </div>
<p> <p>
@@ -150,3 +177,19 @@ const { data: openDataResponse } = await useAsyncData('recent-fixes', () =>
</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>
+105 -60
View File
@@ -1,22 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
// La pagina è statica, usiamo solo i componenti UI import { useMembers } from "~/composables/useMembers";
useHead({ useHead({
title: 'Informazioni sul Progetto - Visione', title: 'Informazioni sul Progetto - Visione',
}) })
const { sortedMembers } = useMembers();
</script> </script>
<template> <template>
<div> <div>
<!-- HERO SECTION (Senza pt-5/mt-5 come richiesto) -->
<div class="it-hero-wrapper it-primary"> <div class="it-hero-wrapper it-primary">
<div class="container"> <div class="container">
<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 per la gestione intelligente delle segnalazioni urbane, La piattaforma digitale della Città Metropolitana di Bari per la partecipazione attiva,
pensata per avvicinare cittadini e pubblica amministrazione. la trasparenza e la gestione condivisa del territorio.
</p> </p>
</div> </div>
</div> </div>
@@ -24,72 +26,89 @@ useHead({
</div> </div>
</div> </div>
<!-- OBIETTIVO DEL PROGETTO -->
<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">Migliorare la qualità della vita in città</h2> <h2 class="mb-4 font-weight-bold">Un nuovo dialogo tra Cittadini ed Ente</h2>
<p class="lead text-muted"> <p class="lead text-muted">
Visione nasce dall'esigenza di fornire alle amministrazioni comunali uno strumento moderno per mappare, gestire e risolvere disservizi presenti sul territorio (buche stradali, illuminazione guasta, rifiuti abbandonati). Il progetto nasce dall'esigenza di digitalizzare e ottimizzare i flussi di comunicazione tra la cittadinanza
e la Pubblica Amministrazione. Sostituisce i tradizionali canali non tracciabili con un unico punto di
accesso telematico.
</p> </p>
<p> <p>
Il sistema non si limita a raccogliere dati, ma automatizza l'assegnazione alle squadre operative competenti tramite logiche geospaziali, traccia l'intero ciclo di vita della segnalazione e restituisce trasparenza ai cittadini tramite un portale Open Data dedicato. L'adozione della piattaforma persegue un triplice obiettivo strategico: ridurre drasticamente i tempi di
risposta
dell'Ente nella risoluzione dei disservizi (quali buche stradali, illuminazione guasta o degrado urbano),
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>
</div> </div>
<!-- RUOLI E FUNZIONALITÀ (Card Grid) -->
<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 funziona il sistema</h2> <h2 class="text-center mb-5 font-weight-bold">Come si articola l'ecosistema digitale</h2>
<div class="row"> <div class="row">
<!-- Cittadino -->
<div class="col-12 col-md-4 mb-4"> <div class="col-12 col-md-4 mb-4">
<div class="card-wrapper card-space h-100"> <div class="card-wrapper card-space h-100">
<div class="card card-bg h-100"> <div class="card card-bg h-100">
<div class="card-body"> <div class="card-body">
<BIIcon name="it-user" size="xl" color="primary" class="mb-3" /> <BIIcon class="mb-3" color="primary" name="it-user" size="xl"/>
<h3 class="card-title h5 font-weight-bold">Cittadini</h3> <h3 class="card-title h5 font-weight-bold">I Cittadini</h3>
<p class="card-text text-muted"> <p class="card-text text-muted">
Tramite autenticazione sicura (OTP via email o CIE), i cittadini possono inviare segnalazioni geolocalizzate complete di foto. L'accesso è garantito attraverso l'autocompletamento anagrafico e il supporto ai sistemi di identità
digitale
nazionali (<strong>CIE</strong>).
<br><br> <br><br>
Per incentivare la partecipazione, è integrato un sistema di <strong>gamification</strong>: ogni segnalazione valida e risolta fa guadagnare Punti Cittadino, premiando la cittadinanza attiva. Possono inoltre chattare con gli operatori e lasciare un feedback finale. Il cittadino può compilare form guidati inserendo descrizioni, documentazione fotografica e
posizione esatta (GPS).
Per incentivare la partecipazione, è previsto il conferimento di "<strong>Punti di Cittadinanza
Attiva</strong>"
per ogni segnalazione validata e risolta dall'Ente.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Operatore -->
<div class="col-12 col-md-4 mb-4"> <div class="col-12 col-md-4 mb-4">
<div class="card-wrapper card-space h-100"> <div class="card-wrapper card-space h-100">
<div class="card card-bg h-100"> <div class="card card-bg h-100">
<div class="card-body"> <div class="card-body">
<BIIcon name="it-tool" size="xl" color="primary" class="mb-3" /> <BIIcon class="mb-3" color="primary" name="it-tool" size="xl"/>
<h3 class="card-title h5 font-weight-bold">Operatori e Squadre</h3> <h3 class="card-title h5 font-weight-bold">Squadre Operative</h3>
<p class="card-text text-muted"> <p class="card-text text-muted">
Il personale comunale visualizza una dashboard con le segnalazioni in ingresso. Possono prenderle in carico, comunicare con l'utente e assegnarle alle squadre di intervento sul territorio. Gli operatori tecnici e logistici dell'Ente prendono in carico la segnalazione per l'immediata
istruttoria.
<br><br> <br><br>
Il sistema gestisce l'intero flusso di stato: da <em>Aperta</em> a <em>In Revisione</em>, fino a <em>In Lavorazione</em> e <em>Risolta</em>. La piattaforma provvede a instradare automaticamente l'intervento alla squadra specializzata di
turno in base alla
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>
</div> </div>
</div> </div>
<!-- Amministratore -->
<div class="col-12 col-md-4 mb-4"> <div class="col-12 col-md-4 mb-4">
<div class="card-wrapper card-space h-100"> <div class="card-wrapper card-space h-100">
<div class="card card-bg h-100"> <div class="card card-bg h-100">
<div class="card-body"> <div class="card-body">
<BIIcon name="it-settings" size="xl" color="primary" class="mb-3" /> <BIIcon class="mb-3" color="primary" name="it-settings" size="xl"/>
<h3 class="card-title h5 font-weight-bold">Amministrazione</h3> <h3 class="card-title h5 font-weight-bold">L'Amministrazione</h3>
<p class="card-text text-muted"> <p class="card-text text-muted">
Gli amministratori hanno il controllo completo della piattaforma. Possono definire le aree urbane tramite poligoni GeoJSON, gestire i permessi degli utenti, supervisionare i feedback e moderare eventuali abusi. Gli amministratori dispongono del controllo completo sulle operazioni, con privilegi di gestione dei
profili e assegnazione delle nomine agli operatori.
<br><br> <br><br>
Hanno inoltre accesso a statistiche avanzate per individuare le criticità sul territorio e monitorare i tempi medi di risoluzione. L'Ente utilizza funzioni di ricerca avanzata e filtraggio per area geografica per prevenire le
duplicazioni
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>
@@ -100,68 +119,91 @@ useHead({
</div> </div>
</div> </div>
<!-- STACK TECNOLOGICO E FILOSOFIA -->
<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-6 mb-4 mb-lg-0"> <div class="col-12 col-lg-7 mb-4 mb-lg-0">
<h2 class="font-weight-bold mb-4">Tecnologia e Indipendenza</h2> <h2 class="font-weight-bold mb-4">Sicurezza, Centralità e Conformità</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> <li class="d-flex align-items-start mb-3">
<BIIcon name="it-code-circle" color="primary" class="me-2" /> <BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-pa"/>
<strong>Architettura:</strong> Frontend reattivo e multipiattaforma sviluppato in <em>Vue.js/Nuxt 3</em>. Backend ad altissime prestazioni scritto in <em>C++</em> utilizzando il framework <em>Drogon</em>. <div>
<strong>Modello Centralizzato:</strong> Tutta la gestione logica e la persistenza dei dati risiedono
lato server, garantendo tracciabilità totale dei passaggi di stato e convalida rigorosa dei permessi
utente.
</div>
</li> </li>
<li class="mt-3"> <li class="d-flex align-items-start mb-3">
<BIIcon name="it-lock" color="primary" class="me-2" /> <BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-lock"/>
<strong>Sicurezza:</strong> Login Passwordless (OTP nativo) e sistema di autorizzazione Role-Based basato su token JWT. <div>
<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="mt-3"> <li class="d-flex align-items-start mb-3">
<BIIcon name="it-piu" color="primary" class="me-2" /> <BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-mail"/>
<strong>Open Data:</strong> Piena aderenza alle linee guida AGID sulla trasparenza, con esportazione di dati anonimizzati accessibili al pubblico. <div>
<strong>Accesso Operatori:</strong> Il personale tecnico comunale accede al sistema tramite un approccio
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">
<!-- Callout Ribellione Gitea --> <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-warning-circle" /> <BIIcon name="it-info-circle"/>
Un approccio etico al codice sorgente Customer Satisfaction Integrata
</div> </div>
<p> <p>
In risposta alle policy sempre più aggressive delle grandi piattaforme commerciali riguardo 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>. La piattaforma non si esaurisce con l'intervento tecnico, ma chiude il ciclo con un modulo dedicato alla
<strong>Valutazione della Qualità</strong>.
<br><br> <br><br>
Una scelta di principio per garantire il controllo totale, la privacy e la vera sovranità sui dati e sul codice. A risoluzione avvenuta, il cittadino viene invitato a esprimere un riscontro sulla tempestività e
sull'efficacia
del lavoro svolto, assicurando all'amministrazione un controllo qualità costante.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- CREDITI ACCADEMICI (Footer Style Callout) -->
<div class="container mb-5 pb-5"> <div class="container mb-5 pb-5">
<div class="card-wrapper card-space"> <div class="card-wrapper card-space">
<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-8"> <div class="col-12 col-md-6 mb-4 mb-md-0">
<h4 class="h5 font-weight-bold text-primary mb-2">Crediti Accademici</h4> <h4 class="h5 font-weight-bold text-primary mb-2">Il Team di Sviluppo</h4>
<p class="mb-1"> <p class="mb-1">
Questo progetto è stato realizzato nell'ambito del corso di <strong>Laboratorio di Programmazione</strong> (tenuto dal Prof. Vessio). Questo portale è stato ideato e sviluppato per facilitare il percorso di transizione digitale
</p> e modernizzare l'infrastruttura telematica a disposizione della cittadinanza.
<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-4 mt-3 mt-md-0 text-md-end"> <div class="col-12 col-md-6">
<p class="font-weight-bold mb-1">Sviluppato da:</p> <div class="d-flex flex-wrap justify-content-md-end justify-content-center gap-4">
<ul class="list-unstyled mb-0"> <div
<li>Renato Loiacono</li> v-for="member in sortedMembers"
<li>Michelangelo Parisi</li> :key="member.name"
<li>Gabriele Schiraldi</li> class="d-flex flex-column align-items-center text-center"
</ul> style="max-width: 90px;"
>
<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>
@@ -170,9 +212,12 @@ useHead({
</template> </template>
<style scoped> <style scoped>
/* Leggere correzioni stilistiche per le liste */
.it-list { .it-list {
list-style-type: none; list-style-type: none;
padding-left: 0; padding-left: 0;
} }
h2 {
text-align: center;
}
</style> </style>
+117
View File
@@ -0,0 +1,117 @@
<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>
-3
View File
@@ -6,7 +6,6 @@ useHead({
<template> <template>
<div> <div>
<!-- HERO SECTION -->
<div class="it-hero-wrapper it-primary"> <div class="it-hero-wrapper it-primary">
<div class="container"> <div class="container">
<div class="row"> <div class="row">
@@ -22,7 +21,6 @@ useHead({
</div> </div>
</div> </div>
<!-- CONTENUTO PRINCIPALE -->
<div class="container my-5 py-4"> <div class="container my-5 py-4">
<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">
@@ -53,7 +51,6 @@ useHead({
Gli operatori e gli amministratori di sistema si riservano il diritto di moderare, nascondere o rimuovere segnalazioni contenenti materiale inappropriato, offensivo, non pertinente o illegale. L'abuso ripetuto del sistema comporterà il ban permanente dell'account dell'utente, associato al proprio Codice Fiscale. Gli operatori e gli amministratori di sistema si riservano il diritto di moderare, nascondere o rimuovere segnalazioni contenenti materiale inappropriato, offensivo, non pertinente o illegale. L'abuso ripetuto del sistema comporterà il ban permanente dell'account dell'utente, associato al proprio Codice Fiscale.
</p> </p>
<!-- DISCLAIMER ACCADEMICO -->
<div class="callout callout-highlight callout-info mt-5"> <div class="callout callout-highlight callout-info mt-5">
<div class="callout-title"> <div class="callout-title">
<BIIcon name="it-info-circle" /> <BIIcon name="it-info-circle" />
+55 -72
View File
@@ -1,11 +1,13 @@
<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 } = useAuth() const { fetchNotifications, markNotificationRead, refreshUnreadNotifications } = useAuth()
const router = useRouter() const router = useRouter()
const page = ref(1) const page = ref(1)
@@ -13,27 +15,25 @@ 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(() => notificationsData.value?.notifications || []) const notifications = computed<Notification[]>(() => 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((n: any) => !n.read_at).length) const unreadCount = computed(() => notifications.value.filter((notification) => !notification.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: any) => { const handleNotificationClick = async (notification: Notification) => {
if (!notification.read_at) { if (!notification.read_at) {
await handleMarkAsRead(notification.id) await handleMarkAsRead(notification.id)
} }
@@ -92,7 +92,6 @@ const getNotificationColor = (type: string): string => {
<template> <template>
<div> <div>
<!-- HERO -->
<div class="bg-light border-bottom py-4 mb-4"> <div class="bg-light border-bottom py-4 mb-4">
<div class="container"> <div class="container">
<div class="row"> <div class="row">
@@ -109,75 +108,65 @@ const getNotificationColor = (type: string): string => {
</div> </div>
</div> </div>
<!-- CONTENUTO -->
<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">
<!-- FILTRI (Opzionale) --> <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>
<!-- LISTA NOTIFICHE --> <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"
type="button" class="notification-row p-3"
class="list-group-item list-group-item-action border-0 p-3 mb-3 rounded-2" :class="{ 'notification-row-unread': !notification.read_at }"
: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">
<!-- ICONA -->
<div class="flex-shrink-0 mt-1"> <div class="flex-shrink-0 mt-1">
<div class="rounded-circle p-2" :class="`bg-${getNotificationColor(notification.type)}`" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;"> <div
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>
<!-- CONTENUTO -->
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="d-flex align-items-start justify-content-between"> <div class="d-flex flex-wrap justify-content-between gap-2">
<div> <div>
<h6 class="mb-1 font-weight-bold" :class="{ 'text-dark': !notification.read_at }"> <h2 class="h6 mb-1">{{ notification.title }}</h2>
{{ notification.title }} <p class="mb-1 text-muted">{{ notification.body }}</p>
</h6> <small class="text-muted">{{ formatDate(notification.created_at) }}</small>
<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>
<!-- INDICATORE NON LETTO -->
<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 rounded-pill">Nuovo</span> <span class="badge bg-primary">Nuovo</span>
</div> </div>
</div> </div>
<!-- AZIONI RAPIDE --> <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-sm btn-outline-secondary" class="btn btn-outline-secondary btn-sm"
@click.stop="handleMarkAsRead(notification.id)" @click.stop="handleMarkAsRead(notification.id)"
> >
Segna come letta Segna come letta
@@ -185,16 +174,16 @@ const getNotificationColor = (type: string): string => {
</div> </div>
</div> </div>
</div> </div>
</button> </div>
</div>
</div> </div>
<!-- PAGINATION -->
<Pagination <Pagination
v-if="notifications.length > 0" v-if="notifications.length > 0"
:current-page="page" :current-page="page"
:per-page="perPage" :per-page="perPage"
:total-items="totalNotifications" :total-items="totalNotifications"
@page-change="handlePageChange" @page-change="page = $event"
/> />
</div> </div>
@@ -205,23 +194,17 @@ const getNotificationColor = (type: string): string => {
</template> </template>
<style scoped> <style scoped>
.list-group-item { .notification-row + .notification-row {
border: 1px solid #e9ecef; border-top: 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);
}
&.bg-light {
border-left: 4px solid #006f9d;
}
} }
.rounded-2 { .notification-row-unread {
border-radius: 0.5rem; border-left: 4px solid #0066cc;
background: #f8fbff;
}
.notification-icon {
width: 40px;
height: 40px;
} }
</style> </style>
+51 -71
View File
@@ -1,52 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { Map as LeafletMap, CircleMarker, LatLngBoundsExpression } from 'leaflet' import type { CircleMarker, LatLngBoundsExpression, Map as LeafletMap } from 'leaflet'
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari' import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
// 1. API Fetching Logic
const { getOpenDataReports } = usePublic() const { getOpenDataReports } = usePublic()
// Reactive pagination state
const currentPage = ref(1) const currentPage = ref(1)
const perPage = ref(9) const perPage = ref(9)
// Fetch data automatically when currentPage changes
const { data: openDataResponse, pending } = await useAsyncData( const { data: openDataResponse, pending } = await useAsyncData(
'open-data-reports', 'open-data-reports',
() => getOpenDataReports({ page: currentPage.value, per_page: perPage.value }), () => getOpenDataReports({ page: 1, per_page: 200 })
{ watch: [currentPage] } // Nuxt will re-fetch when currentPage changes
) )
const visibleReports = computed(() => const allVisibleReports = computed(() =>
(openDataResponse.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude)) (openDataResponse.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude))
) )
// Pagination logic: If we receive less items than `perPage`, we are on the last page. 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 nextPage = () => { const handlePageChange = (page: number) => {
if (!isLastPage.value) currentPage.value++ currentPage.value = page
}
const prevPage = () => {
if (currentPage.value > 1) currentPage.value--
} }
// 2. View Toggle (Grid vs Map) 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)
let leaflet: typeof import('leaflet') | null = null let leaflet: typeof import('leaflet') | null = null
let map: LeafletMap | null = null let map: LeafletMap | null = null
let markers: CircleMarker[] = [] let markers: CircleMarker[] = []
// 3. Helper to style status badges
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
case 'RESOLVED': return { class: 'bg-success', text: 'Risolto', icon: 'it-check-circle' } case 'RESOLVED':
case 'CLOSED': return { class: 'bg-secondary', text: 'Chiuso', icon: 'it-lock' } return { class: 'bg-success', text: 'Risolto', icon: 'it-check-circle' }
default: return { class: 'bg-info', text: status, icon: 'it-info-circle' } case 'CLOSED':
return { class: 'bg-secondary', text: 'Chiuso', icon: 'it-lock' }
default:
return { class: 'bg-info', text: status, icon: 'it-info-circle' }
} }
} }
@@ -70,12 +66,12 @@ const renderMapMarkers = async () => {
clearMarkers() clearMarkers()
if (visibleReports.value.length === 0) { if (mapVisibleReports.value.length === 0) {
map.setView(BARI_CENTER, 12) map.setView(BARI_CENTER, 12)
return return
} }
visibleReports.value.forEach((report) => { mapVisibleReports.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,
@@ -97,7 +93,7 @@ const renderMapMarkers = async () => {
markers.push(marker) markers.push(marker)
}) })
const fitBounds: LatLngBoundsExpression = visibleReports.value.map((report) => [report.latitude, report.longitude]) const fitBounds: LatLngBoundsExpression = mapVisibleReports.value.map((report) => [report.latitude, report.longitude])
map.fitBounds(fitBounds, { padding: [32, 32], maxZoom: 15 }) map.fitBounds(fitBounds, { padding: [32, 32], maxZoom: 15 })
} }
@@ -126,12 +122,15 @@ const initMap = async () => {
watch(currentView, async (view) => { watch(currentView, async (view) => {
if (view !== 'map') return if (view !== 'map') return
await nextTick() await nextTick()
if (!map) {
await initMap() await initMap()
map?.invalidateSize() } else {
map.invalidateSize()
await renderMapMarkers() await renderMapMarkers()
}
}) })
watch(visibleReports, async () => { watch(mapVisibleReports, async () => {
if (currentView.value === 'map' && map) { if (currentView.value === 'map' && map) {
await renderMapMarkers() await renderMapMarkers()
} }
@@ -146,7 +145,6 @@ onBeforeUnmount(() => {
<template> <template>
<div> <div>
<!-- HERO SECTION (Small version for internal pages) -->
<div class="it-hero-wrapper it-primary"> <div class="it-hero-wrapper it-primary">
<div class="container"> <div class="container">
<div class="row"> <div class="row">
@@ -162,19 +160,15 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<!-- MAIN CONTENT --> <div class="container my-5" id="grid-start">
<div class="container my-5">
<!-- TOOLBAR: View Toggles & Export -->
<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">
<!-- View Toggle Buttons -->
<button <button
class="btn btn-sm" class="btn btn-sm"
:class="currentView === 'grid' ? 'btn-primary' : 'btn-outline-primary'" :class="currentView === 'grid' ? 'btn-primary' : 'btn-outline-primary'"
@click="currentView = 'grid'" @click="currentView = 'grid'"
> >
<BIIcon name="it-grid" size="sm" class="me-1" :color="currentView === 'grid' ? 'white' : 'primary'" /> <BIIcon :color="currentView === 'grid' ? 'white' : 'primary'" class="me-1" name="it-grid" size="sm"/>
Griglia Dati Griglia Dati
</button> </button>
<button <button
@@ -182,49 +176,45 @@ onBeforeUnmount(() => {
:class="currentView === 'map' ? 'btn-primary' : 'btn-outline-primary'" :class="currentView === 'map' ? 'btn-primary' : 'btn-outline-primary'"
@click="currentView = 'map'" @click="currentView = 'map'"
> >
<BIIcon name="it-map-marker" size="sm" class="me-1" :color="currentView === 'map' ? 'white' : 'primary'" /> <BIIcon :color="currentView === 'map' ? 'white' : 'primary'" class="me-1" name="it-map-marker" size="sm"/>
Mappa Mappa
</button> </button>
</div> </div>
<div class="col-12 col-md-6 text-md-end"> <div class="col-12 col-md-6 text-md-end">
<!-- Fake Export CTA (Very common in Open Data portals) -->
<button class="btn btn-sm btn-outline-secondary" title="Scarica dataset in formato JSON"> <button class="btn btn-sm btn-outline-secondary" title="Scarica dataset in formato JSON">
<BIIcon name="it-download" size="sm" class="me-1" /> <BIIcon class="me-1" name="it-download" size="sm"/>
Scarica JSON Scarica JSON
</button> </button>
</div> </div>
</div> </div>
<!-- LOADER -->
<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>
</div> </div>
</div> </div>
<!-- GRID VIEW --> <div v-show="!pending && currentView === 'grid'">
<div v-else-if="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">
<!-- Strict BI Card -->
<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">
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-3"> <div class="d-flex justify-content-between align-items-start mb-3">
<div class="category-top m-0"> <div class="category-top m-0">
<span class="badge rounded-pill text-white px-3 py-1" :class="getStatusBadge(report.status).class"> <span :class="getStatusBadge(report.status).class"
<BIIcon :name="getStatusBadge(report.status).icon" size="xs" color="white" class="me-1" /> 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 }} {{ 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>
<p class="card-text text-muted mb-4"> <p class="card-text text-muted mb-4">
<BIIcon name="it-map-marker-circle" size="sm" class="me-1" /> <BIIcon class="me-1" name="it-map-marker-circle" size="sm"/>
{{ report.area_name || 'Area comunale non specificata' }} {{ report.area_name || 'Area comunale non specificata' }}
</p> </p>
@@ -251,33 +241,22 @@ onBeforeUnmount(() => {
Nessun dato pubblico disponibile al momento. Nessun dato pubblico disponibile al momento.
</div> </div>
<!-- BOOTSTRAP ITALIA PAGINATION --> <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>
<!-- MAP VIEW --> <div v-show="!pending && currentView === 'map'" class="map-view-wrapper">
<div v-else-if="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">{{ visibleReports.length }} punti</span> <span class="badge bg-light text-dark border">
<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>
@@ -289,17 +268,18 @@ onBeforeUnmount(() => {
</template> </template>
<style scoped> <style scoped>
/* Ensures the cards inside the grid stretch to equal heights */
.card-wrapper { .card-wrapper {
height: 100%; height: 100%;
} }
.card-bg { .card-bg {
border: 1px solid rgba(0,0,0,0.05); border: 1px solid rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
.card-bg:hover { .card-bg:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0,0,0,0.1) !important; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important;
} }
.open-data-map { .open-data-map {
+3 -3
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">Coda Open</NuxtLink> <NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Segnalazioni disponibili</NuxtLink>
</div> </div>
</div> </div>
@@ -45,10 +45,10 @@ const { data: reportsRes, pending } = useAsyncData(
/> />
<Pagination <Pagination
v-if="reportsRes.total > perPage" v-if="(reportsRes.total || 0) > perPage"
:current-page="page" :current-page="page"
:per-page="perPage" :per-page="perPage"
:total-items="reportsRes.total" :total-items="reportsRes.total!"
@page-change="page = $event" @page-change="page = $event"
/> />
</div> </div>
+5 -18
View File
@@ -6,34 +6,22 @@ definePageMeta({
useHead({ title: 'Segnalazioni Aperte - Operatore' }) useHead({ title: 'Segnalazioni Aperte - Operatore' })
const { getOpenReports, takeReport } = useOperator() const { getOpenReports } = useOperator()
const page = ref(1) const page = ref(1)
const perPage = ref(10) const perPage = ref(10)
const { data: reportsRes, pending, refresh } = useAsyncData( const { data: reportsRes, pending } = 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 prendere in carico (OPEN)</h1> <h1 class="h3 font-weight-bold mb-0">Segnalazioni da valutare</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>
@@ -45,15 +33,14 @@ const handleTakeReport = async (id: number) => {
</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 aperte in questo momento. Non ci sono segnalazioni disponibili 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"
@take="handleTakeReport" action-label="Visualizza"
/> />
<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">Coda Open</NuxtLink> <NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Segnalazioni disponibili</NuxtLink>
</div> </div>
</div> </div>
+508 -58
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Message, ReportStatus } from '~/types/api' import type { FlagRequest, Message, Report, ReportStatus } from '~/types/api'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
@@ -16,23 +16,50 @@ if (!reportId) {
navigateTo('/operator') navigateTo('/operator')
} }
const { getReportDetail, getMessages, addMessage, flagMessage } = useReports() const { getReportDetail, getMessages, addMessage, flagMessage, flagReport, getTeamSuggestions } = useReports()
const { getInterventionSummary, updateReportStatus, assignTeam, getTeams } = useOperator() const {
takeReport,
getInterventionSummary,
updateReportStatus,
assignTeam,
getTeams,
createInterventionLink,
revokeInterventionLink
} = useOperator()
const { user } = useAuth() const { user } = useAuth()
// Dati principali
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 report = computed(() => (reportData.value as any)?.report) const { data: allTeamsData } = await useAsyncData('operator-teams', () => getTeams())
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(() => (messagesData.value as any)?.messages || []) const messages = computed<Message[]>(() => (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',
UNDER_REVIEW: 'In revisione', UNDER_REVIEW: 'In revisione',
@@ -40,22 +67,18 @@ const statusMeta: Record<string, string> = {
IN_PROGRESS: 'In lavorazione', IN_PROGRESS: 'In lavorazione',
RESOLVED: 'Risolta', RESOLVED: 'Risolta',
REJECTED: 'Respinta', REJECTED: 'Respinta',
CLOSED: 'Chiusa' CLOSED: 'Conclusa'
} }
// Form Cambia Stato const statusTransitions: Record<string, string[]> = {
const newStatus = ref<ReportStatus>('IN_PROGRESS') OPEN: ['UNDER_REVIEW', 'REJECTED'],
const statusNote = ref('') UNDER_REVIEW: ['ASSIGNED', 'REJECTED'],
const isUpdatingStatus = ref(false) ASSIGNED: ['IN_PROGRESS', 'REJECTED'],
IN_PROGRESS: ['RESOLVED', 'REJECTED'],
// Form Assegna Squadra RESOLVED: ['CLOSED'],
const isAssignModalOpen = ref(false) REJECTED: ['CLOSED'],
const selectedTeamId = ref<number | null>(null) CLOSED: []
const isAssigning = ref(false) }
// Messaggi
const isSubmittingMessage = ref(false)
const isFlaggingMessageId = ref<number | null>(null)
const availableStatuses: { value: ReportStatus, label: string }[] = [ const availableStatuses: { value: ReportStatus, label: string }[] = [
{ value: 'OPEN', label: 'Aperta' }, { value: 'OPEN', label: 'Aperta' },
@@ -64,16 +87,136 @@ 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: 'Chiusa' } { value: 'CLOSED', label: 'Conclusa' }
] ]
// Inizializza lo stato nel form const selectableStatuses = computed(() => {
if (!report.value?.status) return []
const allowed = statusTransitions[report.value.status] || []
return availableStatuses.filter(s => allowed.includes(s.value))
})
const canCreateInterventionLink = computed(() => {
if (isReadOnlyPreview.value) return false
if (summary.value?.assignments && Array.isArray(summary.value.assignments)) {
return summary.value.assignments.some((a: any) => !a.ended_at)
}
return ['ASSIGNED', 'IN_PROGRESS'].includes(report.value?.status || '')
})
const newStatus = ref<ReportStatus>('IN_PROGRESS')
const statusNote = ref('')
const isUpdatingStatus = ref(false)
const isTakingReport = ref(false)
const isAssignModalOpen = ref(false)
const selectedTeamId = ref<number | null>(null)
const isAssigning = ref(false)
const interventionLink = ref('')
const interventionLinkExpiresAt = ref('')
const interventionPublicLink = ref('')
const isGeneratingInterventionLink = ref(false)
const isRevokingInterventionLink = ref(false)
const interventionLinkError = ref('')
const interventionLinkSuccess = ref('')
const isRevokeInterventionModalOpen = ref(false)
const copyFeedback = ref('')
const actionError = ref('')
const actionSuccess = ref('')
const isSubmittingMessage = ref(false)
const flaggedMessageIds = ref<number[]>([])
const isFlagMessageModalOpen = ref(false)
const messageToFlag = ref<Message | null>(null)
const isFlaggingMessage = ref(false)
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) newStatus.value = val as ReportStatus if (val) {
const allowed = statusTransitions[val] || []
if (allowed.length > 0) {
newStatus.value = allowed[0] as ReportStatus
}
}
}, { 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,
@@ -82,17 +225,22 @@ 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) { } catch (e: any) {
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 (!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
@@ -101,22 +249,104 @@ 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) { } catch (e: any) {
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 () => {
if (!interventionPublicLink.value || !navigator?.clipboard) return
try {
await navigator.clipboard.writeText(interventionPublicLink.value)
copyFeedback.value = 'Copiato'
interventionLinkSuccess.value = 'Link copiato negli appunti.'
interventionLinkError.value = ''
setTimeout(() => {
copyFeedback.value = ''
}, 2000)
} catch {
interventionLinkError.value = 'Copia automatica non riuscita.'
interventionLinkSuccess.value = ''
copyFeedback.value = ''
}
}
const handleCreateInterventionLink = async () => {
if (!canCreateInterventionLink.value) return
isGeneratingInterventionLink.value = true
clearActionFeedback()
interventionLinkError.value = ''
interventionLinkSuccess.value = ''
try {
const res = await createInterventionLink(reportId)
if (res.ok) {
interventionLink.value = res.url
interventionLinkExpiresAt.value = res.expires_at
setPublicInterventionLink(res.url)
interventionLinkSuccess.value = 'Link intervento generato.'
}
} catch (e: any) {
console.error('Failed to create intervention link', e)
interventionLinkError.value = e?.data?.message || 'Generazione link non riuscita.'
} finally {
isGeneratingInterventionLink.value = false
}
}
const openRevokeInterventionLinkModal = () => {
if (!interventionLink.value) return
isRevokeInterventionModalOpen.value = true
}
const closeRevokeInterventionLinkModal = () => {
if (isRevokingInterventionLink.value) return
isRevokeInterventionModalOpen.value = false
}
const handleRevokeInterventionLink = async () => {
if (!interventionLink.value) return
isRevokingInterventionLink.value = true
clearActionFeedback()
interventionLinkError.value = ''
interventionLinkSuccess.value = ''
try {
const res = await revokeInterventionLink(reportId)
if (res.ok) {
interventionLink.value = ''
interventionLinkExpiresAt.value = ''
interventionPublicLink.value = ''
interventionLinkSuccess.value = 'Link intervento revocato.'
isRevokeInterventionModalOpen.value = false
}
} catch (e: any) {
console.error('Failed to revoke intervention link', e)
interventionLinkError.value = e?.data?.message || 'Revoca link non riuscita.'
} finally {
isRevokingInterventionLink.value = false
}
}
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()
} catch (e) { actionSuccess.value = 'Messaggio inviato.'
} 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
} }
@@ -124,21 +354,64 @@ 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
return 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)
return isCitizenMessage && !isAlreadyFlagged
} }
const handleFlagMessage = async (message: Message) => { const handleFlagMessage = (message: Message) => {
if (!canFlagMessage(message)) return if (!canFlagMessage(message)) return
isFlaggingMessageId.value = message.id messageToFlag.value = message
isFlagMessageModalOpen.value = true
}
const executeFlagMessage = async () => {
if (!messageToFlag.value) return
isFlaggingMessage.value = true
try { try {
await flagMessage(reportId, message.id, { await flagMessage(reportId, messageToFlag.value.id, {
reason: 'Messaggio segnalato da operatore responsabile per verifica moderazione', reason: 'Messaggio segnalato da operatore responsabile per verifica moderazione',
}) })
} catch (e) {
flaggedMessageIds.value.push(messageToFlag.value.id)
isFlagMessageModalOpen.value = false
messageToFlag.value = null
actionSuccess.value = 'Messaggio segnalato alla moderazione.'
} 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 {
isFlaggingMessageId.value = null 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
} }
} }
@@ -158,20 +431,25 @@ const latestCitizenMessage = computed(() => {
.reverse() .reverse()
.find((message) => message.user_id === reportUserId) || null .find((message) => message.user_id === reportUserId) || null
}) })
const formattedInterventionLinkExpiry = computed(() =>
interventionLinkExpiresAt.value ? formatDate(interventionLinkExpiresAt.value) : ''
)
</script> </script>
<template> <template>
<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="/operator/assigned" class="btn btn-sm btn-link text-decoration-none ps-0 mb-2"> <NuxtLink :to="isTakenByCurrentOperator ? '/operator/assigned' : '/operator'" class="btn btn-sm btn-link text-decoration-none ps-0 mb-2">
&larr; Torna alle assegnazioni &larr; Torna all'elenco
</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">Intervento #{{ reportId }}</span> <span class="report-id">Segnalazione #{{ 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>
@@ -184,15 +462,26 @@ const latestCitizenMessage = 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> </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 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">
@@ -233,6 +522,20 @@ const latestCitizenMessage = 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>
@@ -254,7 +557,13 @@ const latestCitizenMessage = computed(() => {
:is-flagging-message-id="isFlaggingMessageId" :is-flagging-message-id="isFlaggingMessageId"
@flag="handleFlagMessage" @flag="handleFlagMessage"
/> />
<MessageComposer @send="handleSendMessage" /> <div v-if="isReadOnlyPreview" class="alert alert-secondary mt-3 mb-0 small">
{{ 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>
@@ -270,8 +579,8 @@ const latestCitizenMessage = 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 disponibili</span> <span>Team suggeriti</span>
<strong>{{ teams.length }}</strong> <strong :class="{'text-primary': suggestedTeams.length > 0}">{{ suggestedTeams.length }}</strong>
</li> </li>
<li> <li>
<span>Allegati</span> <span>Allegati</span>
@@ -285,21 +594,84 @@ const latestCitizenMessage = computed(() => {
</p> </p>
</div> </div>
</div> </div>
<div class="card-body pt-0"> <div class="card-body pt-0" v-if="!isReadOnlyPreview">
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="teams.length === 0"> <button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="!canAssignTeam">
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 class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h6 mb-3">Link intervento pubblico</h2>
<p class="text-muted small mb-3">Genera link temporaneo per team esterno. Nuova generazione ruota link esistente.</p>
<div v-if="interventionLinkError" class="alert alert-danger py-2" role="alert">
{{ interventionLinkError }}
</div>
<div v-if="interventionLinkSuccess" class="alert alert-success py-2" role="alert">
{{ interventionLinkSuccess }}
</div>
<template v-if="interventionPublicLink">
<label class="form-label">URL attivo</label>
<div class="input-group mb-2">
<input type="text" class="form-control" :value="interventionPublicLink" readonly />
<button type="button" class="btn btn-outline-primary" @click="copyInterventionLink">
{{ copyFeedback || 'Copia' }}
</button>
</div>
<p class="small text-muted mb-3">
Scade: {{ formattedInterventionLinkExpiry || '-' }}
</p>
<div class="d-grid gap-2" v-if="!isReadOnlyPreview">
<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>
Rigenera link
</button>
<button type="button" class="btn btn-outline-danger" :disabled="isRevokingInterventionLink" @click="openRevokeInterventionLinkModal">
<span v-if="isRevokingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
Revoca link
</button>
</div>
</template>
<button
v-else
type="button"
class="btn btn-outline-primary w-100"
:disabled="isGeneratingInterventionLink || !canCreateInterventionLink"
@click="handleCreateInterventionLink"
>
<span v-if="isGeneratingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
Genera link
</button>
<div v-if="!canCreateInterventionLink" class="text-danger small mt-2">
È necessaria una presa in carico e un'assegnazione in corso per generare questo link.
</div>
</div> </div>
</div> </div>
<div class="card card-bg border-0 shadow-sm mb-4"> <div class="card card-bg border-0 shadow-sm mb-4">
<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">
<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>
<select class="form-select" v-model="newStatus"> <select class="form-select" v-model="newStatus">
<option v-for="st in availableStatuses" :key="st.value" :value="st.value"> <option v-for="st in selectableStatuses" :key="st.value" :value="st.value">
{{ st.label }} {{ st.label }}
</option> </option>
</select> </select>
@@ -308,11 +680,18 @@ const latestCitizenMessage = computed(() => {
<label class="form-label">Nota (visibile nella cronologia)</label> <label class="form-label">Nota (visibile nella cronologia)</label>
<textarea class="form-control" v-model="statusNote" rows="2" placeholder="Motivazione o dettaglio..."></textarea> <textarea class="form-control" v-model="statusNote" rows="2" placeholder="Motivazione o dettaglio..."></textarea>
</div> </div>
<button type="submit" class="btn btn-primary w-100" :disabled="isUpdatingStatus || newStatus === report.status"> <button type="submit" class="btn btn-primary w-100" :disabled="isUpdatingStatus">
<span v-if="isUpdatingStatus" class="spinner-border spinner-border-sm me-2"></span> <span v-if="isUpdatingStatus" class="spinner-border spinner-border-sm me-2"></span>
Aggiorna Stato Aggiorna Stato
</button> </button>
</form> </form>
</template>
<template v-else>
<div class="alert alert-info mb-0 small">
La segnalazione è attualmente in stato <strong>{{ statusMeta[report.status] }}</strong>. Non sono consentite ulteriori transizioni.
</div>
</template>
</div> </div>
</div> </div>
@@ -338,33 +717,53 @@ const latestCitizenMessage = computed(() => {
</div> </div>
</div> </div>
<!-- Modale Assegnazione --> <!-- 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>
<div class="modal-body"> <!-- FORM AGGIORNATO CON MODAL-BODY E MODAL-FOOTER -->
<form @submit.prevent="handleAssignTeam"> <form @submit.prevent="handleAssignTeam">
<div class="mb-3"> <div class="modal-body">
<div class="mb-0">
<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">
{{ team.name }}<template v-if="team.category_name"> · {{ team.category_name }}</template> <optgroup v-if="suggestedTeams.length > 0" label="Team Suggeriti">
<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="teams.length === 0" class="form-text text-danger"> <small v-if="allTeams.length === 0" class="form-text text-danger mt-1">
Nessun team attivo disponibile. Nessun team attivo disponibile.
</small> </small>
</div> </div>
<div class="d-flex justify-content-end gap-2 mt-4"> </div>
<button type="button" class="btn btn-outline-secondary" @click="isAssignModalOpen = false">Annulla</button> <div class="modal-footer">
<button type="submit" class="btn btn-primary" :disabled="isAssigning || !selectedTeamId"> <button class="btn btn-outline-secondary" type="button" @click="isAssignModalOpen = false">Annulla
</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>
@@ -373,8 +772,59 @@ const latestCitizenMessage = 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
:open="isRevokeInterventionModalOpen"
title="Revoca link intervento"
message="Vuoi revocare link pubblico per team esterno? Link non sara piu utilizzabile."
confirm-label="Revoca link"
confirm-class="btn-danger"
:pending="isRevokingInterventionLink"
@cancel="closeRevokeInterventionLinkModal"
@confirm="handleRevokeInterventionLink"
/>
<ConfirmModal
:open="isFlagMessageModalOpen"
title="Segnala messaggio"
message="Sei sicuro di voler segnalare questo messaggio al team di moderazione per una verifica?"
confirm-label="Segnala"
confirm-class="btn-warning"
:pending="isFlaggingMessage"
@cancel="isFlagMessageModalOpen = false; messageToFlag = null"
@confirm="executeFlagMessage"
/>
</div> </div>
<div v-else class="text-center py-5"> <div v-else class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div> <div class="spinner-border text-primary" role="status"></div>
+1 -3
View File
@@ -6,7 +6,6 @@ useHead({
<template> <template>
<div> <div>
<!-- HERO SECTION -->
<div class="it-hero-wrapper it-primary"> <div class="it-hero-wrapper it-primary">
<div class="container"> <div class="container">
<div class="row"> <div class="row">
@@ -22,7 +21,6 @@ useHead({
</div> </div>
</div> </div>
<!-- CONTENUTO PRINCIPALE -->
<div class="container my-5 py-4"> <div class="container my-5 py-4">
<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">
@@ -48,7 +46,7 @@ useHead({
<h2 class="h3 font-weight-bold mb-3">3. Finalità del Trattamento</h2> <h2 class="h3 font-weight-bold mb-3">3. Finalità del Trattamento</h2>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
I dati forniti verranno trattati esclusivamente per le seguenti finalità: I dati forniti verranno trattati esclusivamente per le seguenti finalità:
<br>- Consentire la registrazione e l'autenticazione sicura (incluso login Passwordless tramite OTP). <br>- Consentire la registrazione e l'autenticazione sicura tramite OTP.
<br>- Gestire la presa in carico e la risoluzione delle segnalazioni urbane da parte delle squadre operative. <br>- Gestire la presa in carico e la risoluzione delle segnalazioni urbane da parte delle squadre operative.
<br>- Fornire comunicazioni di servizio relative allo stato di avanzamento delle segnalazioni. <br>- Fornire comunicazioni di servizio relative allo stato di avanzamento delle segnalazioni.
<br>- Attribuire punteggi virtuali all'utente nell'ambito del sistema di <em>Gamification</em> volto a incentivare la cittadinanza attiva. <br>- Attribuire punteggi virtuali all'utente nell'ambito del sistema di <em>Gamification</em> volto a incentivare la cittadinanza attiva.
+9 -3
View File
@@ -71,7 +71,7 @@ const formatDate = (dateString: string) =>
</div> </div>
<div class="col-md-6 mb-0"> <div class="col-md-6 mb-0">
<small class="text-muted d-block">Ruolo</small> <small class="text-muted d-block">Ruolo</small>
<span class="badge bg-info">{{ user?.role }}</span> <span class="badge bg-info">{{ normalizeRole(user?.role) }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -130,9 +130,9 @@ 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 to="/reports/new" class="btn btn-primary">Nuova segnalazione</NuxtLink> <NuxtLink v-if="isCitizen(user?.role)" class="btn btn-primary" to="/reports/new">Nuova segnalazione
</NuxtLink>
<NuxtLink to="/notifications" class="btn btn-outline-primary">Notifiche</NuxtLink> <NuxtLink to="/notifications" class="btn btn-outline-primary">Notifiche</NuxtLink>
<NuxtLink to="/auth/change-password" class="btn btn-outline-secondary">Cambia password</NuxtLink>
<button type="button" class="btn btn-outline-danger" @click="logout"> <button type="button" class="btn btn-outline-danger" @click="logout">
Logout Logout
</button> </button>
@@ -154,3 +154,9 @@ const formatDate = (dateString: string) =>
</div> </div>
</div> </div>
</template> </template>
<style>
.card .card-body .d-grid > a {
padding-block: 8px;
}
</style>
+98 -73
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
}) })
@@ -6,7 +8,6 @@ definePageMeta({
useHead({ title: 'Dettaglio Segnalazione - Visione' }) useHead({ title: 'Dettaglio Segnalazione - Visione' })
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const route = useRoute() const route = useRoute()
const { getReportDetail, getMessages, addMessage, addFeedback } = useReports() const { getReportDetail, getMessages, addMessage, addFeedback } = useReports()
const { user } = useAuth() const { user } = useAuth()
@@ -16,7 +17,6 @@ if (!reportId) {
navigateTo('/') navigateTo('/')
} }
// Fetch dati
const { data: reportData, refresh: refreshReport } = await useAsyncData(`report-${reportId}`, () => const { data: reportData, refresh: refreshReport } = await useAsyncData(`report-${reportId}`, () =>
getReportDetail(reportId) getReportDetail(reportId)
) )
@@ -30,23 +30,31 @@ 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(() => (messagesData.value as any)?.messages || []) const messages = computed(() => (messagesData.value as any)?.messages || [])
const newMessage = ref('')
const isSubmittingMessage = ref(false) const isSubmittingMessage = ref(false)
const isFeedbackOpen = ref(false) 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 selectedMediaIndex = ref(0) const submittedFeedback = ref<{ rating: number; comment?: string } | null>(null)
const isGalleryOpen = ref(false)
// Invia messaggio // Carica l'eventuale feedback da local storage dopo il mount (per evitare mismatch nel SSR)
const handleSendMessage = async (msgText: string, files: File[]) => { onMounted(() => {
if (!msgText.trim() && files.length === 0) return 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[]) => {
isSubmittingMessage.value = true isSubmittingMessage.value = true
try { try {
// Gestione mock di file, nella realtà si usa FormData o simle await addMessage(reportId, { body: messageText })
await addMessage(reportId, { body: msgText })
await refreshMessages() await refreshMessages()
} catch (error) { } catch (error) {
console.error('Error sending message:', error) console.error('Error sending message:', error)
@@ -55,17 +63,21 @@ const handleSendMessage = async (msgText: string, files: File[]) => {
} }
} }
// Invia feedback (solo se risolta)
const handleSubmitFeedback = async () => { const handleSubmitFeedback = async () => {
isSubmittingFeedback.value = true isSubmittingFeedback.value = true
try { try {
await addFeedback(reportId, { const feedbackData = {
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)
@@ -75,7 +87,14 @@ 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' || report.value?.status === 'CLOSED') 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' },
@@ -83,7 +102,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: 'Chiusa', tone: 'secondary' }, CLOSED: { label: 'Conclusa', tone: 'secondary' },
} }
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
@@ -105,7 +124,7 @@ const formatDate = (dateString: string): string => {
<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 #{{ report.id }}</span> <span class="report-id">Segnalazione #{{ report.id }}</span>
<StatusBadge :status="report.status" /> <StatusBadge :status="report.status"/>
</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>
@@ -132,7 +151,7 @@ const formatDate = (dateString: string): string => {
<div class="card card-bg border-0 shadow-sm mb-4"> <div class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
<div class="report-section-head mb-3"> <div class="report-section-head mb-3">
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" /> <BIIcon class="me-2" color="primary" name="it-map-marker-circle" size="sm"/>
<h2 class="h6 mb-0">Posizione e dettagli</h2> <h2 class="h6 mb-0">Posizione e dettagli</h2>
</div> </div>
<dl class="report-detail-grid mb-0"> <dl class="report-detail-grid mb-0">
@@ -164,46 +183,40 @@ const formatDate = (dateString: string): string => {
<div v-if="media.length > 0" class="card card-bg border-0 shadow-sm mb-4"> <div v-if="media.length > 0" class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
<div class="report-section-head mb-3"> <div class="report-section-head mb-3">
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" /> <BIIcon class="me-2" color="primary" name="it-camera" size="sm"/>
<h2 class="h6 mb-0">Allegati ({{ media.length }})</h2> <h2 class="h6 mb-0">Allegati ({{ media.length }})</h2>
</div> </div>
<div class="row g-3"> <div class="row g-3">
<div v-for="(m, idx) in media" :key="m.id" class="col-6 col-md-4 mb-3"> <div v-for="(m, idx) in media" :key="m.id" class="col-6 col-md-4 mb-3">
<div v-if="m.media_kind === 'IMAGE'" class="ratio ratio-1x1 bg-light rounded overflow-hidden report-media-frame"> <div v-if="m.media_kind === 'IMAGE'"
<img :src="runtimeConfig.public.api + m.url" :alt="`Allegato ${Number(idx) + 1}`" class="object-fit-cover" /> class="ratio ratio-1x1 bg-light rounded overflow-hidden report-media-frame">
<img :alt="`Allegato ${Number(idx) + 1}`" :src="runtimeConfig.public.api + m.url"
class="object-fit-cover"/>
</div> </div>
<div v-else class="ratio ratio-1x1 bg-dark rounded d-flex align-items-center justify-content-center report-media-frame"> <div v-else
<BIIcon name="it-video" size="xl" color="white" /> class="ratio ratio-1x1 bg-dark rounded d-flex align-items-center justify-content-center report-media-frame">
<BIIcon color="white" name="it-video" size="xl"/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="statusHistory.length > 0" class="card card-bg border-0 shadow-sm mb-4"> <div class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
<div class="report-section-head mb-4"> <h2 class="h6 mb-3">Cronologia eventi</h2>
<BIIcon name="it-history" size="sm" color="primary" class="me-2" /> <div v-if="statusHistory.length === 0" class="small text-muted">Nessun evento registrato.</div>
<h2 class="h6 mb-0">Cronologia</h2> <ul class="list-unstyled mb-0 ms-2">
</div> <li v-for="item in statusHistory" :key="item.id" class="mb-3 border-start border-primary border-3 ps-3">
<div class="timeline"> <small class="text-muted d-block">{{ formatDate(item.created_at) }}</small>
<div v-for="item in statusHistory" :key="item.id" class="timeline-item mb-3"> <div class="d-flex align-items-center gap-2 mt-1">
<div class="d-flex gap-3"> <StatusBadge :status="item.old_status"/>
<div class="flex-shrink-0"> <BIIcon name="it-arrow-right" size="xs"/>
<div class="timeline-marker"></div> <StatusBadge :status="item.new_status"/>
</div>
<div class="flex-grow-1">
<small class="text-muted">{{ formatDate(item.created_at) }}</small>
<div class="small font-weight-semibold d-flex align-items-center gap-2 mt-1">
<StatusBadge :status="item.old_status" />
<BIIcon name="it-arrow-right" size="xs" />
<StatusBadge :status="item.new_status" />
</div>
<small v-if="item.note" class="text-muted d-block">{{ item.note }}</small>
</div>
</div>
</div>
</div> </div>
<div v-if="item.note" class="small mt-1 bg-light p-1 rounded">{{ item.note }}</div>
</li>
</ul>
</div> </div>
</div> </div>
@@ -212,15 +225,23 @@ const formatDate = (dateString: string): string => {
<div class="report-chat-header mb-3"> <div class="report-chat-header mb-3">
<div> <div>
<div class="report-section-head mb-1"> <div class="report-section-head mb-1">
<BIIcon name="it-comment" size="sm" color="primary" class="me-2" /> <BIIcon class="me-2" color="primary" name="it-comment" size="sm"/>
<h2 class="h6 mb-0">Chat segnalazione</h2> <h2 class="h6 mb-0">Chat segnalazione</h2>
</div> </div>
<p class="text-muted small mb-0">Usa chat per chiarimenti, aggiornamenti operativi e richieste rapide.</p> <p class="text-muted small mb-0">Usa chat per chiarimenti, aggiornamenti operativi e richieste
rapide.</p>
</div> </div>
<span class="badge bg-light text-dark border">{{ messages.length }} messaggi</span> <span class="badge bg-light text-dark border">{{ messages.length }} messaggi</span>
</div> </div>
<MessageList :messages="messages" />
<MessageComposer @send="handleSendMessage" /> <MessageList :messages="messages"/>
<MessageComposer v-if="canChat" @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>
@@ -250,6 +271,30 @@ 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"
@@ -285,7 +330,8 @@ const formatDate = (dateString: string): string => {
</div> </div>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<button type="button" class="btn btn-primary" @click="handleSubmitFeedback" :disabled="isSubmittingFeedback"> <button :disabled="isSubmittingFeedback" class="btn btn-primary" type="button"
@click="handleSubmitFeedback">
<span v-if="isSubmittingFeedback" class="spinner-border spinner-border-sm me-2"></span> <span v-if="isSubmittingFeedback" class="spinner-border spinner-border-sm me-2"></span>
Invia Invia
</button> </button>
@@ -294,6 +340,7 @@ const formatDate = (dateString: string): string => {
</button> </button>
</div> </div>
</template> </template>
</template>
</div> </div>
</div> </div>
@@ -408,25 +455,6 @@ const formatDate = (dateString: string): string => {
justify-content: space-between; justify-content: space-between;
} }
.timeline-marker {
width: 12px;
height: 12px;
background-color: #006f9d;
border-radius: 50%;
border: 3px solid #f0f0f0;
}
.timeline-item:not(:last-child) .timeline-marker::after {
content: '';
position: absolute;
left: 50%;
top: 20px;
width: 2px;
height: calc(100% + 20px);
background-color: #006f9d;
transform: translateX(-50%);
}
.ratio { .ratio {
position: relative; position: relative;
display: block; display: block;
@@ -457,6 +485,3 @@ const formatDate = (dateString: string): string => {
} }
} }
</style> </style>
+3
View File
@@ -3,6 +3,9 @@ import type { CreateReportRequest } from '~/types/api'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',
auth: {
role: ['!ADMIN', '!OPERATOR']
}
}) })
useHead({ title: 'Nuova segnalazione - Visione' }) useHead({ title: 'Nuova segnalazione - Visione' })
+67 -32
View File
@@ -6,6 +6,7 @@ export type FlagContentType = 'REPORT' | 'MESSAGE';
export interface GeoJsonObject { export interface GeoJsonObject {
type: string; type: string;
coordinates: any[]; coordinates: any[];
[key: string]: any; [key: string]: any;
} }
@@ -19,9 +20,6 @@ export interface OkResponse {
ok: boolean; ok: boolean;
} }
/**
* USER & AUTHENTICATION
*/
export interface CurrentUser { export interface CurrentUser {
id: number; id: number;
first_name: string; first_name: string;
@@ -29,7 +27,6 @@ export interface CurrentUser {
email: string; email: string;
fiscal_code: string; fiscal_code: string;
points: number; points: number;
must_change_password: boolean;
role: string; role: string;
roles: string[]; roles: string[];
permissions: string[]; permissions: string[];
@@ -45,8 +42,7 @@ export interface AdminUser {
phone?: string; phone?: string;
banned?: boolean; banned?: boolean;
points?: number; points?: number;
must_change_password?: boolean; roles?: string;
roles?: string; // Comma-separated
} }
export interface TokenResponse { export interface TokenResponse {
@@ -56,9 +52,41 @@ export interface TokenResponse {
expires_in_hours: number; expires_in_hours: number;
} }
/** export interface CieStartRequest {
* REPORTS & MEDIA frontend_redirect_uri: string;
*/ }
export interface CieStartResponse {
ok: boolean;
authorization_url: string;
state: string;
status: string;
}
export interface CieExchangeRequest {
state: string;
issuer: string;
subject: string;
fiscal_code: string;
first_name?: string;
last_name?: string;
email?: string;
}
export interface CieSessionAuth {
token?: string;
token_type?: string;
expires_in_hours?: number;
[key: string]: any;
}
export interface CieSessionResponse {
ok: boolean;
status: string;
user_id?: number;
auth?: CieSessionAuth;
}
export interface Report { export interface Report {
id: number; id: number;
category_id: number; category_id: number;
@@ -120,9 +148,6 @@ export interface Message {
created_at: string; created_at: string;
} }
/**
* ENTITIES (Areas, Teams, Categories, Notifications, Rewards, Moderation)
*/
export interface Area { export interface Area {
id: number; id: number;
name: string; name: string;
@@ -184,30 +209,16 @@ export interface ModerationFlag {
resolved_at?: string | null; resolved_at?: string | null;
} }
/**
* REQUEST BODIES
*/
export interface RegisterCitizenRequest { export interface RegisterCitizenRequest {
first_name: string; first_name: string;
last_name: string; last_name: string;
email: string; email: string;
password: string;
fiscal_code: string; fiscal_code: string;
phone?: string; phone?: string;
birth_date?: string; birth_date?: string;
birth_place?: string; birth_place?: string;
} }
export interface PasswordLoginRequest {
email: string;
password: string;
}
export interface ChangePasswordRequest {
current_password: string;
new_password: string;
}
export interface UpdateProfileRequest { export interface UpdateProfileRequest {
first_name?: string; first_name?: string;
last_name?: string; last_name?: string;
@@ -229,7 +240,7 @@ export interface CreateReportRequest {
longitude: number; longitude: number;
address: string; address: string;
description: string; description: string;
media?: string[]; // Array of URLs if already uploaded media?: string[];
} }
export interface UpdateStatusRequest { export interface UpdateStatusRequest {
@@ -246,8 +257,8 @@ export interface MessageRequest {
} }
export interface FeedbackRequest { export interface FeedbackRequest {
rating: number; // 1 to 5 rating: number;
comment?: string; comment?: string;
} }
export interface FlagRequest { export interface FlagRequest {
@@ -267,11 +278,24 @@ export interface CreateAreaRequest {
description?: string; description?: string;
} }
export interface UpdateAreaRequest {
name?: string;
boundary_geojson?: GeoJsonObject;
description?: string;
active?: boolean;
}
export interface CreateCategoryRequest { export interface CreateCategoryRequest {
name: string; name: string;
description?: string; description?: string;
} }
export interface UpdateCategoryRequest {
name?: string;
description?: string;
active?: boolean;
}
export interface CreateTeamRequest { export interface CreateTeamRequest {
team_category_id: number; team_category_id: number;
name: string; name: string;
@@ -279,6 +303,14 @@ export interface CreateTeamRequest {
area_id?: number; area_id?: number;
} }
export interface UpdateTeamRequest {
team_category_id?: number;
name?: string;
description?: string;
area_id?: number | null;
active?: boolean;
}
export interface UpdateUserBanRequest { export interface UpdateUserBanRequest {
banned: boolean; banned: boolean;
} }
@@ -287,9 +319,6 @@ export interface UpdateUserRolesRequest {
roles: string[]; roles: string[];
} }
/**
* RESPONSE BODIES
*/
export interface CurrentUserResponse { export interface CurrentUserResponse {
ok: boolean; ok: boolean;
user: CurrentUser; user: CurrentUser;
@@ -346,6 +375,12 @@ export interface InterventionSummaryResponse {
latest_messages: Message[]; latest_messages: Message[];
} }
export interface AssignmentLinkResponse {
ok: boolean;
url: string;
expires_at: string;
}
export interface MediaUploadResponse { export interface MediaUploadResponse {
ok: boolean; ok: boolean;
media: Media[]; media: Media[];
@@ -1,3 +1,5 @@
import { BARI_BOUNDS, isWithinBariBounds } from '~/utils/bari'
interface NominatimAddress { interface NominatimAddress {
road?: string road?: string
house_number?: string house_number?: string
@@ -27,26 +29,8 @@ export interface GeocodingSuggestion {
} }
const NOMINATIM_BASE_URL = 'https://nominatim.openstreetmap.org' const NOMINATIM_BASE_URL = 'https://nominatim.openstreetmap.org'
const BARI_BOUNDS = {
north: 41.1696,
south: 41.0718,
east: 16.9521,
west: 16.7564
}
const NOMINATIM_HEADERS = {
'User-Agent': 'Visione Frontend/1.0 (https://visione.studentindipendenti.it)',
'Referer': 'https://visione.studentindipendenti.it/'
}
const bariViewbox = `${BARI_BOUNDS.west},${BARI_BOUNDS.north},${BARI_BOUNDS.east},${BARI_BOUNDS.south}` const bariViewbox = `${BARI_BOUNDS.west},${BARI_BOUNDS.north},${BARI_BOUNDS.east},${BARI_BOUNDS.south}`
const isWithinBariBounds = (latitude: number, longitude: number) =>
latitude >= BARI_BOUNDS.south &&
latitude <= BARI_BOUNDS.north &&
longitude >= BARI_BOUNDS.west &&
longitude <= BARI_BOUNDS.east
const compactAddress = (displayName: string, address?: NominatimAddress) => { const compactAddress = (displayName: string, address?: NominatimAddress) => {
if (!address) { if (!address) {
return displayName return displayName
@@ -91,9 +75,7 @@ export const searchAddresses = async (query: string, limit = 5) => {
limit: String(limit) limit: String(limit)
}) })
const results = await $fetch<NominatimResult[]>(`${NOMINATIM_BASE_URL}/search?${params.toString()}`, { const results = await $fetch<NominatimResult[]>(`${NOMINATIM_BASE_URL}/search?${params.toString()}`)
headers: NOMINATIM_HEADERS
})
return results return results
.map(toSuggestion) .map(toSuggestion)
@@ -111,9 +93,7 @@ export const reverseGeocode = async (latitude: number, longitude: number) => {
'accept-language': 'it' 'accept-language': 'it'
}) })
const result = await $fetch<NominatimResult | null>(`${NOMINATIM_BASE_URL}/reverse?${params.toString()}`, { const result = await $fetch<NominatimResult | null>(`${NOMINATIM_BASE_URL}/reverse?${params.toString()}`)
headers: NOMINATIM_HEADERS
})
if (!result) { if (!result) {
return '' return ''
+74
View File
@@ -0,0 +1,74 @@
export const ROLES = {
CITIZEN: 'CITIZEN',
OPERATOR: 'OPERATOR',
ADMIN: 'ADMIN'
} as const
export type Role = keyof typeof ROLES
export type NegatedRole = `!${Role}`
export type MetaRoleRequirement = Role | NegatedRole | Array<Role | NegatedRole>
export const ROLE_LEVELS: Record<string, number> = {
[ROLES.CITIZEN]: 1,
[ROLES.OPERATOR]: 2,
[ROLES.ADMIN]: 3
}
export function normalizeRole(role?: string | null): string {
return role?.trim().toUpperCase() ?? ''
}
export function isRole(userRole: string | undefined | null, targetRole: string): boolean {
return normalizeRole(userRole) === normalizeRole(targetRole)
}
export function hasMinimumRole(userRole: string | undefined | null, requiredRole: string): boolean {
const userLvl = ROLE_LEVELS[normalizeRole(userRole)] || 0
const reqLvl = ROLE_LEVELS[normalizeRole(requiredRole)] || 0
return userLvl >= reqLvl
}
export function isAdmin(userRole?: string | null): boolean {
return isRole(userRole, ROLES.ADMIN)
}
export function isOperator(userRole?: string | null): boolean {
return isRole(userRole, ROLES.OPERATOR)
}
export function isCitizen(userRole?: string | null): boolean {
return isRole(userRole, ROLES.CITIZEN)
}
export function canAccess(userRole: string | undefined | null, requiredRole: string): boolean {
return hasMinimumRole(userRole, requiredRole)
}
export function checkRoleAccess(userRole: string | undefined | null, requirement: MetaRoleRequirement | undefined): boolean {
if (!requirement) return true
const normalizedUserRole = normalizeRole(userRole)
if (!normalizedUserRole) return false
const requirements = Array.isArray(requirement) ? requirement : [requirement]
for (const req of requirements) {
const isNegated = req.startsWith('!')
const rawTarget = isNegated ? req.substring(1) : req
const targetRole = normalizeRole(rawTarget)
if (isNegated) {
console.log("negated", normalizedUserRole, targetRole)
if (normalizedUserRole === targetRole) {
return false
}
} else {
if (!hasMinimumRole(normalizedUserRole, targetRole)) {
return false
}
}
}
return true
}
+42 -24
View File
@@ -6,9 +6,12 @@ export default defineNuxtConfig({
'@nuxt/image', '@nuxt/image',
'@nuxt/fonts', '@nuxt/fonts',
'@nuxtjs/sitemap', '@nuxtjs/sitemap',
'@nuxtjs/robots' '@nuxtjs/robots',
'@vite-pwa/nuxt'
], ],
ssr: true,
runtimeConfig: { runtimeConfig: {
public: { public: {
url: 'http://localhost:3000', url: 'http://localhost:3000',
@@ -35,34 +38,49 @@ export default defineNuxtConfig({
] ]
}, },
nitro: {
devProxy: {
'/api/v1': {
target: 'https://bk.visione.studentindipendenti.it/api/v1',
changeOrigin: true,
headers: {
host: 'bk.visione.studentindipendenti.it',
origin: 'https://visione.studentindipendenti.it',
referer: 'https://visione.studentindipendenti.it/'
}
},
'/uploads': {
target: 'https://bk.visione.studentindipendenti.it/uploads',
changeOrigin: true,
headers: {
host: 'bk.visione.studentindipendenti.it',
origin: 'https://visione.studentindipendenti.it',
referer: 'https://visione.studentindipendenti.it/'
}
}
}
},
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,6 +14,7 @@
"@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.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+4 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1940.28 1402.502">
<path d="M1750.752 723.02a183 183 0 0 0-.378-15.172c-12.607-201.546-306.563-352.873-691.458-375.02L1104.93 0c-32.106 110.228-64.212 220.499-96.318 330.727-52.026-1.47-105.564-.546-160.069 2.858C365.313 363.842-14.414 580.516.42 817.489c14.877 236.972 418.642 404.52 901.83 374.263 115.02-7.186 224.197-24.962 323.584-51.059l255.63 261.809-207.975-275.172c271.389-81.148 459.572-225.752 476.087-383.425 63.582-4.329 127.122-8.657 190.704-12.985-63.162-2.648-126.366-5.253-189.528-7.9M1022.27 597.998c256.178 5.84 455.202 49.924 515.884 116.153-176.29-7.312-352.621-14.666-528.91-22.02 4.328-31.392 8.656-62.742 13.027-94.133ZM39.713 849.637C34.628 768.32 215.876 686.753 480.08 639.182c-69.381 32.568-116.742 97.747-116.742 172.801 0 100.605 85.056 183.392 194.234 193.814-296.562-8.867-512.438-69.381-517.86-156.16m771.22 151.664c-73.878 4.623-145.444 6.303-213.27 5.379 16.599-.84 32.652-3.74 47.906-8.531-16.64 4.664-34.207 7.564-52.32 8.447-2.898.168-5.882.252-8.824.294a165 165 0 0 1-13.742-.714c-99.765-8.573-178.055-92.285-178.055-194.277 0-78.71 46.605-146.536 113.8-177.34 22.778-10.464 47.95-16.641 74.467-17.523h.294q3.214-.127 6.43-.127c9.287 0 18.364.63 27.315 1.891.547-.084 1.093-.126 1.64-.21a77 77 0 0 1-2.396-1.554l6.009 1.134c53.244-6.514 108.758-11.64 165.826-15.213 49.504-3.11 97.915-4.874 144.898-5.421-7.229 24.879-14.499 49.715-21.727 74.592L620.187 618.17a106 106 0 0 0-3.614.42c.252.21.504.378.756.547h.043c11.052 1.722 21.81 4.328 32.19 7.858.21.042.42.084.588.168 28.66 9.666 54.379 25.845 75.307 46.773 35.342 35.3 57.194 84.047 57.194 137.963 0 2.27-.042 4.497-.126 6.724-2.774 82.493-56.816 152.042-131.324 177.72-.841.293-1.724.587-2.606.881 10.338-2.941 20.298-6.64 29.837-10.926 90.561-37.023 159.648-102.664 250-150.193 44.923 46.016 89.846 91.99 134.77 138.006-78.669 12.313-163.557 21.642-252.269 27.19m335.686-41.814-125.483-165.994c178.727-12.145 357.412-24.332 536.139-36.519 1.513 77.408-165.195 154.774-410.656 202.513"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1940.28 1402.502">
<path d="M1750.752 723.02a183 183 0 0 0-.378-15.172c-12.607-201.546-306.563-352.873-691.458-375.02L1104.93 0c-32.106 110.228-64.212 220.499-96.318 330.727-52.026-1.47-105.564-.546-160.069 2.858C365.313 363.842-14.414 580.516.42 817.489c14.877 236.972 418.642 404.52 901.83 374.263 115.02-7.186 224.197-24.962 323.584-51.059l255.63 261.809-207.975-275.172c271.389-81.148 459.572-225.752 476.087-383.425 63.582-4.329 127.122-8.657 190.704-12.985-63.162-2.648-126.366-5.253-189.528-7.9M1022.27 597.998c256.178 5.84 455.202 49.924 515.884 116.153-176.29-7.312-352.621-14.666-528.91-22.02 4.328-31.392 8.656-62.742 13.027-94.133ZM39.713 849.637C34.628 768.32 215.876 686.753 480.08 639.182c-69.381 32.568-116.742 97.747-116.742 172.801 0 100.605 85.056 183.392 194.234 193.814-296.562-8.867-512.438-69.381-517.86-156.16m771.22 151.664c-73.878 4.623-145.444 6.303-213.27 5.379 16.599-.84 32.652-3.74 47.906-8.531-16.64 4.664-34.207 7.564-52.32 8.447-2.898.168-5.882.252-8.824.294a165 165 0 0 1-13.742-.714c-99.765-8.573-178.055-92.285-178.055-194.277 0-78.71 46.605-146.536 113.8-177.34 22.778-10.464 47.95-16.641 74.467-17.523h.294q3.214-.127 6.43-.127c9.287 0 18.364.63 27.315 1.891.547-.084 1.093-.126 1.64-.21a77 77 0 0 1-2.396-1.554l6.009 1.134c53.244-6.514 108.758-11.64 165.826-15.213 49.504-3.11 97.915-4.874 144.898-5.421-7.229 24.879-14.499 49.715-21.727 74.592L620.187 618.17a106 106 0 0 0-3.614.42c.252.21.504.378.756.547h.043c11.052 1.722 21.81 4.328 32.19 7.858.21.042.42.084.588.168 28.66 9.666 54.379 25.845 75.307 46.773 35.342 35.3 57.194 84.047 57.194 137.963 0 2.27-.042 4.497-.126 6.724-2.774 82.493-56.816 152.042-131.324 177.72-.841.293-1.724.587-2.606.881 10.338-2.941 20.298-6.64 29.837-10.926 90.561-37.023 159.648-102.664 250-150.193 44.923 46.016 89.846 91.99 134.77 138.006-78.669 12.313-163.557 21.642-252.269 27.19m335.686-41.814-125.483-165.994c178.727-12.145 357.412-24.332 536.139-36.519 1.513 77.408-165.195 154.774-410.656 202.513"
style="fill:#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 90 KiB

-19
View File
@@ -1,19 +0,0 @@
import { createError, getQuery } from 'h3'
import { reverseGeocode } from '../../utils/geocoding'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const latitude = typeof query.lat === 'string' ? Number.parseFloat(query.lat) : Number.NaN
const longitude = typeof query.lng === 'string' ? Number.parseFloat(query.lng) : Number.NaN
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid coordinates'
})
}
const address = await reverseGeocode(latitude, longitude)
return { address }
})
-20
View File
@@ -1,20 +0,0 @@
import { createError, getQuery } from 'h3'
import { searchAddresses } from '../../utils/geocoding'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const rawText = typeof query.q === 'string' ? query.q.trim() : ''
const rawLimit = typeof query.limit === 'string' ? Number.parseInt(query.limit, 10) : 5
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 8) : 5
if (rawText.length < 3) {
throw createError({
statusCode: 400,
statusMessage: 'Query too short'
})
}
const suggestions = await searchAddresses(rawText, limit)
return { suggestions }
})
+2212 -51
View File
File diff suppressed because it is too large Load Diff