Compare commits
@@ -1,2 +1,2 @@
|
||||
NUXT_PUBLIC_URL=http://localhost:3000
|
||||
NUXT_PUBLIC_API=http://localhost:5555
|
||||
# Public URL of the website
|
||||
NUXT_SITE_URL=http://localhost:3000
|
||||
|
||||
@@ -22,7 +22,3 @@ logs
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
pnpm-*.yaml
|
||||
.pnpm-store/
|
||||
AGENTS.md
|
||||
@@ -1,28 +0,0 @@
|
||||
# BUILD
|
||||
FROM node:22-bookworm AS build
|
||||
WORKDIR /app
|
||||
|
||||
ARG NUXT_PUBLIC_API
|
||||
ENV NUXT_PUBLIC_API $NUXT_PUBLIC_API
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install
|
||||
|
||||
COPY . ./
|
||||
|
||||
RUN yarn run build
|
||||
|
||||
# RUN
|
||||
FROM node:22-bookworm AS run
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --production
|
||||
|
||||
COPY --from=build /app/.output/ ./
|
||||
|
||||
ENV PORT=80
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["node", "/app/server/index.mjs"]
|
||||
@@ -1,125 +1,75 @@
|
||||
<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>
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
**Visione** è una piattaforma progettata per gestire le segnalazioni dei cittadini, implementando un flusso operativo
|
||||
sicuro, trasparente e istituzionalmente conforme.
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Punti di Forza e Funzionalità Principali
|
||||
## Setup
|
||||
|
||||
- **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
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
### 2. Configurazione Variabili d'Ambiente (.env)
|
||||
## Development Server
|
||||
|
||||
Copia il file di esempio per creare la tua configurazione locale:
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Il file `.env` richiede le seguenti chiavi:
|
||||
## Production
|
||||
|
||||
- `NUXT_PUBLIC_URL`: URL pubblico dell'applicativo frontend (es. `http://localhost:3000`).
|
||||
- `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
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
yarn run dev
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Il server sarà accessibile all'indirizzo [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
### 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).
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
useSeoMeta({
|
||||
title: "Visione",
|
||||
ogTitle: "Visione",
|
||||
title: "Visione - UniBA",
|
||||
ogTitle: "Visione - UniBA",
|
||||
description: "Un'occhio per la sicurezza del cittadino.",
|
||||
ogDescription: "Un'occhio per la sicurezza del cittadino.",
|
||||
ogImage: "/favicon.png"
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<NuxtPwaManifest/>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
|
||||
@@ -1,386 +1,3 @@
|
||||
:root {
|
||||
--visione-primary: #06c;
|
||||
--visione-primary-dark: #17324d;
|
||||
--visione-text-muted: #455b71;
|
||||
--visione-text-body: #17324d;
|
||||
--visione-text-soft: #455b71;
|
||||
--visione-success: #008758;
|
||||
--visione-warning: #a66300;
|
||||
--visione-danger: #d9364f;
|
||||
--visione-info: #2f80ed;
|
||||
--visione-light-bg: #f0f6fc;
|
||||
--visione-home-hero-bg: var(--visione-light-bg);
|
||||
--visione-font-sans: "Titillium Web", Arial, sans-serif;
|
||||
--visione-font-mono: "Roboto Mono", monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
color: var(--visione-text-body);
|
||||
font-family: var(--visione-font-sans);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: var(--visione-font-sans);
|
||||
}
|
||||
|
||||
select,
|
||||
.form-select {
|
||||
background-color: #f7f9fc !important;
|
||||
border-color: #c5d0db !important;
|
||||
color: var(--visione-text-body) !important;
|
||||
}
|
||||
|
||||
.form-select:focus,
|
||||
select:focus {
|
||||
background-color: #fff !important;
|
||||
border-color: var(--visione-primary) !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15) !important;
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
samp {
|
||||
font-family: var(--visione-font-mono);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
.h1,
|
||||
.h2,
|
||||
.h3,
|
||||
.h4,
|
||||
.h5,
|
||||
.h6,
|
||||
.card-title,
|
||||
.callout-title {
|
||||
color: var(--visione-primary-dark);
|
||||
font-family: var(--visione-font-sans);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p,
|
||||
li,
|
||||
td,
|
||||
th,
|
||||
label,
|
||||
small,
|
||||
.card-text,
|
||||
.card .card-body .card-text,
|
||||
.callout p,
|
||||
.form-text {
|
||||
color: inherit;
|
||||
font-family: var(--visione-font-sans) !important;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: var(--visione-text-soft);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.text-muted,
|
||||
.text-secondary {
|
||||
color: var(--visione-text-soft) !important;
|
||||
}
|
||||
|
||||
.text-dark {
|
||||
color: var(--visione-text-body) !important;
|
||||
}
|
||||
|
||||
.text-primary,
|
||||
.category.text-primary {
|
||||
color: var(--visione-primary) !important;
|
||||
}
|
||||
|
||||
.text-success,
|
||||
.category.text-success {
|
||||
color: var(--visione-success) !important;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--visione-warning) !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--visione-danger) !important;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
color: var(--visione-info) !important;
|
||||
}
|
||||
|
||||
.display-1 {
|
||||
font-size: clamp(3rem, 5vw, 4.75rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.display-3 {
|
||||
font-size: clamp(2.5rem, 4vw, 3.5rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.display-6 {
|
||||
font-size: clamp(1.75rem, 2.4vw, 2.25rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.bg-light {
|
||||
background-color: var(--visione-home-hero-bg) !important;
|
||||
}
|
||||
|
||||
html body .navbar .navbar-collapsable .navbar-nav li.nav-item a.nav-link[data-focus-mouse="true"], .navbar .navbar-collapsable .navbar-nav li.nav-item button.nav-link[data-focus-mouse="true"] {
|
||||
border-color: inherit !important
|
||||
}
|
||||
|
||||
.it-hero-wrapper.it-primary,
|
||||
.it-hero-wrapper.it-primary-bg {
|
||||
background: var(--visione-primary) !important;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.it-hero-wrapper.it-primary h1,
|
||||
.it-hero-wrapper.it-primary p,
|
||||
.it-hero-wrapper.it-primary-bg h1,
|
||||
.it-hero-wrapper.it-primary-bg p {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.it-hero-wrapper .container {
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.it-hero-wrapper.hero-compact .container {
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.it-hero-wrapper .container > .row {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.it-hero-wrapper .it-hero-text-wrapper {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.it-hero-wrapper .it-hero-text-wrapper h1,
|
||||
.it-hero-wrapper .it-hero-text-wrapper p {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.it-hero-wrapper.bg-light .it-hero-text-wrapper p {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.bg-light h1,
|
||||
.bg-light h2,
|
||||
.bg-light h3,
|
||||
.bg-light h4,
|
||||
.bg-light h5,
|
||||
.bg-light h6 {
|
||||
color: var(--visione-primary-dark) !important;
|
||||
}
|
||||
|
||||
.bg-light p,
|
||||
.bg-light .lead,
|
||||
.bg-light .text-muted,
|
||||
.bg-light .text-secondary {
|
||||
color: var(--visione-text-soft) !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: currentColor;
|
||||
fill: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-primary {
|
||||
color: var(--visione-primary) !important;
|
||||
fill: var(--visione-primary) !important;
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
color: var(--visione-success) !important;
|
||||
fill: var(--visione-success) !important;
|
||||
}
|
||||
|
||||
.icon-warning {
|
||||
color: var(--visione-warning) !important;
|
||||
fill: var(--visione-warning) !important;
|
||||
}
|
||||
|
||||
.icon-danger {
|
||||
color: var(--visione-danger) !important;
|
||||
fill: var(--visione-danger) !important;
|
||||
}
|
||||
|
||||
.icon-info {
|
||||
color: var(--visione-info) !important;
|
||||
fill: var(--visione-info) !important;
|
||||
}
|
||||
|
||||
.btn-primary .icon:not([class*="icon-"]),
|
||||
.badge[class*="bg-"] .icon:not([class*="icon-"]),
|
||||
.rounded-circle[class*="bg-"] .icon:not([class*="icon-"]) {
|
||||
color: #fff;
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.btn-xs {
|
||||
--bs-btn-padding-y: 0.25rem;
|
||||
--bs-btn-padding-x: 0.55rem;
|
||||
--bs-btn-font-size: 0.875rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.btn-icon.btn-xs {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
height: 2rem;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.report-table-wrapper {
|
||||
border: 1px solid #dfe4ea;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.report-table thead th {
|
||||
background: #f7f9fa;
|
||||
color: var(--visione-text-soft);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
padding: 0.65rem 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.report-table tbody td {
|
||||
font-size: 0.9375rem;
|
||||
padding: 0.75rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.report-table-id {
|
||||
color: var(--visione-primary-dark);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.report-address {
|
||||
color: var(--visione-text-soft);
|
||||
display: inline-block;
|
||||
max-width: min(36vw, 26rem);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-stat {
|
||||
border-left: 4px solid var(--visione-primary);
|
||||
}
|
||||
|
||||
.dashboard-stat .stat-value {
|
||||
color: var(--visione-primary-dark);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
padding-block: 3rem;
|
||||
}
|
||||
|
||||
.page-shell-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-shell-title {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.page-shell-subtitle {
|
||||
color: var(--visione-text-soft);
|
||||
margin-bottom: 0;
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dfe4ea;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.it-header-wrapper .header-slim-right-zone .btn .rounded-icon,
|
||||
.it-header-wrapper .header-slim-right-zone .btn .rounded-icon .icon {
|
||||
|
||||
}
|
||||
|
||||
.it-footer,
|
||||
.it-footer a,
|
||||
.it-footer h2,
|
||||
.it-footer h3,
|
||||
.it-footer h4,
|
||||
.it-footer .it-brand-text,
|
||||
.it-footer .it-brand-text h2,
|
||||
.it-footer .it-brand-text h3,
|
||||
.it-footer .member-name,
|
||||
.it-footer .icon {
|
||||
color: #fff !important;
|
||||
fill: #fff !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.page-shell {
|
||||
padding-block: 2rem;
|
||||
}
|
||||
|
||||
.it-hero-wrapper .container {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.it-hero-wrapper .it-hero-text-wrapper {
|
||||
padding-left: 1rem !important;
|
||||
padding-right: 1rem !important;
|
||||
}
|
||||
|
||||
.report-address {
|
||||
max-width: 14rem;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-position-y: center;
|
||||
}
|
||||
|
||||
.card::after {
|
||||
margin-top: 0!important;
|
||||
text-decoration: none!important;
|
||||
}
|
||||
@@ -1,24 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
const iconAliases: Record<string, string> = {
|
||||
'it-bell': 'it-info-circle',
|
||||
'it-grid': 'it-list',
|
||||
'it-history': 'it-clock',
|
||||
'it-home': 'it-pa',
|
||||
'it-image': 'it-file-image',
|
||||
'it-location': 'it-map-marker-circle',
|
||||
'it-map': 'it-map-marker-circle',
|
||||
'it-piu': 'it-plus',
|
||||
'it-star': 'it-star-full',
|
||||
'it-write': 'it-pencil',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
name: string
|
||||
size?: 'xs' | 'sm' | 'lg' | 'xl'
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'white'
|
||||
}>()
|
||||
|
||||
const resolvedName = computed(() => iconAliases[props.name] || props.name)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,6 +15,6 @@ const resolvedName = computed(() => iconAliases[props.name] || props.name)
|
||||
]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use :href="`/sprites.svg#${resolvedName}`"></use>
|
||||
<use :href="`/sprites.svg#${name}`"></use>
|
||||
</svg>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,60 +0,0 @@
|
||||
<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>
|
||||
@@ -1,58 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import MemberDot from "~/components/MemberDot.vue";
|
||||
import BIIcon from "~/components/BIIcon.vue";
|
||||
import { useMembers } from "~/composables/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>
|
||||
|
||||
<template>
|
||||
<footer class="it-footer">
|
||||
<div class="it-footer-main">
|
||||
<div class="container footer-main-content">
|
||||
<section class="footer-brand-section">
|
||||
<div class="it-brand-wrapper">
|
||||
<NuxtLink to="/" data-focus-mouse="false" class="footer-brand-link">
|
||||
<img alt="Logo Visione" class="footer-brand-logo" src="/logo-light.svg"/>
|
||||
<div class="it-brand-text">
|
||||
<h2>Visione</h2>
|
||||
<h3>Un occhio per la sicurezza di Bari</h3>
|
||||
<div class="container">
|
||||
<section>
|
||||
<div class="row clearfix">
|
||||
<div class="col-sm-12">
|
||||
<div class="it-brand-wrapper">
|
||||
<a href="#" data-focus-mouse="false">
|
||||
<BIIcon name="it-code-circle" />
|
||||
<div class="it-brand-text">
|
||||
<h2>Visione</h2>
|
||||
<h3 class="d-none d-md-block">Segnalazioni anonime e sicure</h3>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="footer-content-section">
|
||||
<div class="footer-support">
|
||||
<h4>Assistenza per i cittadini</h4>
|
||||
<ul class="footer-support-list">
|
||||
<li v-for="item in supportItems" :key="item.label">
|
||||
<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"
|
||||
:key="member.name"
|
||||
class="footer-member-card"
|
||||
>
|
||||
<img :src="member.image" :alt="member.name" class="footer-member-image">
|
||||
<div class="footer-member-name">{{ member.name }}</div>
|
||||
<section class="py-4 border-white border-top">
|
||||
<div class="row">
|
||||
<div class="col-12 pb-2">
|
||||
<div class="pb-2">
|
||||
<h4>Progetto di</h4>
|
||||
<div class="members-list d-flex justify-content-center align-items-center gap-5 flex-wrap w-100">
|
||||
<MemberDot
|
||||
v-for="member in sortedMembers"
|
||||
:key="member.name"
|
||||
:name="member.name"
|
||||
:image="member.image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,225 +46,13 @@ const supportItems = [
|
||||
</div>
|
||||
<div class="it-footer-small-prints clearfix">
|
||||
<div class="container">
|
||||
<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="/privacy">Privacy policy</a></li>
|
||||
<ul class="it-footer-small-prints-list list-inline mb-0 d-flex flex-column flex-md-row">
|
||||
<li class="list-inline-item"><a href="#">Note legali</a></li>
|
||||
<li class="list-inline-item"><a href="#">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="https://form.agid.gov.it/view/xyz">Dichiarazione di accessibilità <span class="visually-hidden">(link esterno su sito AgID)</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</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>
|
||||
</template>
|
||||
@@ -1,40 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BIIcon from "~/components/BIIcon.vue";
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const isMobileMenuOpen = ref(false)
|
||||
|
||||
watch(() => route.path, () => {
|
||||
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 = [
|
||||
{ name: 'Home', path: '/' },
|
||||
...(isCitizen(user.value?.role) ? [{ name: 'Fai una segnalazione', path: '/reports/new' }] : []),
|
||||
{ name: 'Fai una segnalazione', path: '/reports/create' },
|
||||
{ name: 'Esplora i dati', path: '/open-data' },
|
||||
{ name: 'Come funziona', path: '/info' }
|
||||
]
|
||||
|
||||
const isAuthenticated = ref(false) // Replace with: const token = useCookie('auth_token'); computed(() => !!token.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,55 +28,25 @@ const navLinks = [
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="it-header-slim-wrapper-content">
|
||||
<a class="d-none d-lg-block navbar-brand" href="https://www.cittametropolitana.ba.it/">
|
||||
Città metropolitana di Bari
|
||||
</a>
|
||||
<a class="d-none d-lg-block navbar-brand" href="https://www.interno.gov.it/it/ministero/dipartimenti/dipartimento-pubblica-sicurezza">Dipartimento della Pubblica Sicurezza</a>
|
||||
<div class="nav-mobile">
|
||||
<nav aria-label="Navigazione secondaria">
|
||||
<a class="it-opener d-lg-none" data-bs-toggle="collapse"
|
||||
href="https://www.cittametropolitana.ba.it/"
|
||||
role="button" aria-expanded="false">
|
||||
<span>Città metropolitana di Bari</span>
|
||||
<BIIcon name="it-expand"/>
|
||||
<a class="it-opener d-lg-none" data-bs-toggle="collapse" href="https://www.interno.gov.it/it/ministero/dipartimenti/dipartimento-pubblica-sicurezza" role="button" aria-expanded="false" >
|
||||
<span>Dipartmento della Pubblica Sicureazza</span>
|
||||
<BIIcon name="it-expand" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="header-slim-right-zone" role="navigation">
|
||||
<div v-if="isAuthenticated" class="d-flex align-items-center gap-2">
|
||||
<NuxtLink class="btn btn-primary btn-icon btn-full" to="/dashboard"
|
||||
data-element="personal-area-dashboard" data-focus-mouse="false">
|
||||
<span class="rounded-icon" aria-hidden="true">
|
||||
<BIIcon name="it-list" color="black"/>
|
||||
</span>
|
||||
<span class="d-none d-lg-block">{{ panelLabel }}</span>
|
||||
<div class="it-header-slim-right-zone">
|
||||
<div class="it-access-top-wrapper">
|
||||
<!-- Dynamic Login / Area Personale Button -->
|
||||
<NuxtLink v-if="!isAuthenticated" class="btn btn-primary btn-sm" to="/auth/login">
|
||||
Accedi
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="btn btn-primary btn-icon btn-full" to="/profile"
|
||||
data-element="personal-area-dashboard" data-focus-mouse="false">
|
||||
<span class="rounded-icon profile-icon-wrap" aria-hidden="true">
|
||||
<BIIcon name="it-user" color="black"/>
|
||||
<span v-if="hasUnreadNotifications" class="notification-dot"></span>
|
||||
</span>
|
||||
<span class="d-none d-lg-block username">{{ user?.first_name }}</span>
|
||||
<NuxtLink v-else class="btn btn-primary btn-sm" to="/dashboard">
|
||||
Area Personale
|
||||
</NuxtLink>
|
||||
|
||||
<button class="btn btn-primary btn-icon btn-full border-0" @click="logout"
|
||||
data-element="personal-area-logout" data-focus-mouse="false">
|
||||
<span class="rounded-icon" aria-hidden="true">
|
||||
<BIIcon name="it-logout" color="black"/>
|
||||
</span>
|
||||
<span class="d-none d-lg-block">Esci</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NuxtLink v-else class="btn btn-primary btn-icon btn-full" to="/auth/login"
|
||||
data-element="personal-area-login" data-focus-mouse="false">
|
||||
<span class="rounded-icon" aria-hidden="true">
|
||||
<BIIcon name="it-user" color="black"/>
|
||||
</span>
|
||||
<span class="d-none d-lg-block">Login</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,6 +54,7 @@ const navLinks = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER HEADER (Brand) -->
|
||||
<div class="it-nav-wrapper">
|
||||
<div class="it-header-center-wrapper">
|
||||
<div class="container-xxl">
|
||||
@@ -108,10 +63,10 @@ const navLinks = [
|
||||
<div class="it-header-center-content-wrapper">
|
||||
<div class="it-brand-wrapper">
|
||||
<NuxtLink to="/">
|
||||
<img alt="Logo Visione" class="logo" src="/logo-light.svg"/>
|
||||
<BIIcon name="it-pa" />
|
||||
<div class="it-brand-text">
|
||||
<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>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
@@ -126,30 +81,32 @@ const navLinks = [
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<nav class="navbar navbar-expand-lg has-megamenu" aria-label="Navigazione principale">
|
||||
|
||||
<button
|
||||
class="custom-navbar-toggler"
|
||||
type="button"
|
||||
aria-label="Mostra la navigazione"
|
||||
@click="isMobileMenuOpen = true"
|
||||
>
|
||||
<BIIcon name="it-burger"/>
|
||||
<BIIcon name="it-burger" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="overlay"
|
||||
:class="{ 'show': isMobileMenuOpen }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
style="display: block;"
|
||||
v-show="isMobileMenuOpen"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="navbar-collapsable"
|
||||
:class="{ 'expanded': isMobileMenuOpen }"
|
||||
style="display: block;"
|
||||
>
|
||||
<div class="close-div">
|
||||
<button class="btn close-menu" type="button" @click="isMobileMenuOpen = false">
|
||||
<span class="visually-hidden">Nascondi la navigazione</span>
|
||||
<BIIcon name="it-close-big" color="black"/>
|
||||
<BIIcon name="it-close-big" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -184,137 +141,13 @@ const navLinks = [
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.it-header-slim-wrapper) {
|
||||
background-color: #036;
|
||||
.overlay {
|
||||
transition: opacity 0.3s ease;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:deep(.it-nav-wrapper > div) {
|
||||
background-color: rgb(0, 76.5, 153);
|
||||
.overlay.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
:deep(.it-header-slim-wrapper .it-header-slim-wrapper-content .header-slim-right-zone a),
|
||||
:deep(.it-header-slim-wrapper .it-header-slim-wrapper-content .header-slim-right-zone button) {
|
||||
background-color: rgb(0, 76.5, 153);
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -1,170 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
modelValue?: File[]
|
||||
maxFiles?: number
|
||||
acceptedTypes?: string
|
||||
maxFileSize?: number // in MB
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', files: File[]): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
maxFiles: 5,
|
||||
acceptedTypes: 'image/*,video/*',
|
||||
maxFileSize: 50,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const isDragging = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const files = computed({
|
||||
get: () => props.modelValue || [],
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const handleFilesSelected = (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles) return
|
||||
error.value = ''
|
||||
|
||||
const newFiles = Array.from(selectedFiles)
|
||||
|
||||
if (files.value.length + newFiles.length > props.maxFiles) {
|
||||
error.value = `Massimo ${props.maxFiles} file consentiti`
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (file.size > props.maxFileSize! * 1024 * 1024) {
|
||||
error.value = `Il file ${file.name} supera il limite di ${props.maxFileSize}MB`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
files.value = [...files.value, ...newFiles]
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const openFileDialog = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
const mb = Math.round(bytes / (1024 * 1024) * 10) / 10
|
||||
return mb > 0 ? `${mb}MB` : 'Piccolo'
|
||||
}
|
||||
|
||||
const getFileUrl = (file: File) => {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-uploader">
|
||||
<div
|
||||
:class="{ 'border-primary bg-primary bg-opacity-10': isDragging, 'bg-light': !isDragging }"
|
||||
class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer position-relative"
|
||||
@click="!disabled && openFileDialog()"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="isDragging = false; handleFilesSelected(($event as DragEvent).dataTransfer?.files || null)"
|
||||
>
|
||||
<BIIcon class="mb-3" color="primary" name="it-upload" size="xl"/>
|
||||
<div class="h6 font-weight-bold">
|
||||
Trascina i file qui o <span class="text-primary text-decoration-underline">clicca per selezionare</span>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-2">
|
||||
Massimo {{ maxFiles }} file ({{ maxFileSize }}MB max per file). Tipi: {{ acceptedTypes }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
:accept="acceptedTypes"
|
||||
:disabled="disabled"
|
||||
:multiple="maxFiles > 1"
|
||||
class="d-none"
|
||||
type="file"
|
||||
@change="handleFilesSelected(($event.target as HTMLInputElement).files)"
|
||||
/>
|
||||
|
||||
<div v-if="error" class="alert alert-danger mt-3 py-2 small" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="files.length > 0" class="mt-4 row g-3">
|
||||
<div v-for="(file, idx) in files" :key="`file-${idx}`" class="col-6 col-md-4 col-lg-3">
|
||||
<div class="card h-100 position-relative shadow-sm border-0">
|
||||
<div class="ratio ratio-1x1 bg-light rounded-top overflow-hidden">
|
||||
<img v-if="file.type.startsWith('image/')" :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>
|
||||
<div v-else class="d-flex align-items-center justify-content-center bg-secondary bg-opacity-25">
|
||||
<BIIcon color="secondary" name="it-file" size="xl"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
style="width: 28px; height: 28px; z-index: 10;"
|
||||
title="Rimuovi file"
|
||||
type="button"
|
||||
@click.stop="removeFile(idx)"
|
||||
>
|
||||
<BIIcon color="white" name="it-close" size="sm"/>
|
||||
</button>
|
||||
|
||||
<div class="card-body p-2 d-flex flex-column justify-content-between">
|
||||
<div :title="file.name" class="small text-truncate fw-semibold">{{ file.name }}</div>
|
||||
<div class="small text-muted d-flex justify-content-between align-items-center mt-1">
|
||||
<span>{{ formatFileSize(file.size) }}</span>
|
||||
<div class="progress flex-grow-1 ms-2" style="height: 4px;" title="Pronto per l'upload">
|
||||
<div class="progress-bar bg-success w-100" role="progressbar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-area {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.upload-area:hover:not(.border-primary) {
|
||||
background-color: #f8f9fa !important;
|
||||
border-color: #0066cc !important;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.border-dashed {
|
||||
border-style: dashed !important;
|
||||
}
|
||||
|
||||
.border-2 {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.object-fit-cover {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Emits {
|
||||
(e: 'send', message: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const message = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (message.value.trim() === '') return
|
||||
|
||||
emit('send', message.value)
|
||||
message.value = ''
|
||||
}
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-composer mt-3">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
|
||||
<div class="d-flex align-items-stretch gap-2">
|
||||
<textarea
|
||||
v-model="message"
|
||||
class="form-control chat-textarea m-0"
|
||||
placeholder="Scrivi aggiornamento o domanda"
|
||||
:disabled="isSubmitting"
|
||||
@keydown="handleKeydown"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-square flex-shrink-0"
|
||||
:disabled="isSubmitting || message.trim() === ''"
|
||||
title="Invia"
|
||||
>
|
||||
<span v-if="isSubmitting" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<BIIcon v-else color="white" name="it-arrow-right" size="sm"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<small class="text-muted d-block mt-2">
|
||||
`Ctrl` + `Invio` per inviare.
|
||||
</small>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-composer {
|
||||
border-top: 1px solid #d9e3ee;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.chat-textarea {
|
||||
resize: none !important;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
overflow-y: auto;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chat-textarea:focus {
|
||||
border-color: #06c;
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
|
||||
}
|
||||
|
||||
.btn-square {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,220 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Message } from '~/types/api'
|
||||
|
||||
interface Props {
|
||||
messages: Message[]
|
||||
isLoading?: boolean
|
||||
canFlagMessage?: (message: Message) => boolean
|
||||
isFlaggingMessageId?: number | null
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
isLoading: false,
|
||||
canFlagMessage: () => false,
|
||||
isFlaggingMessageId: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'flag', message: Message): void
|
||||
}>()
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return 'Ieri'
|
||||
} else {
|
||||
return date.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: '2-digit' })
|
||||
}
|
||||
}
|
||||
|
||||
const auth = useAuth()
|
||||
|
||||
const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userId
|
||||
|
||||
const senderInitials = (msg: Message): string =>
|
||||
`${msg.first_name?.[0] || ''}${msg.last_name?.[0] || ''}`.toUpperCase() || '?'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-list">
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Caricamento...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="messages.length === 0" class="message-empty-state" role="alert">
|
||||
<BIIcon class="mb-2" color="primary" name="it-comment" size="lg"/>
|
||||
<strong class="d-block mb-1">Nessun messaggio</strong>
|
||||
<span>Usa chat per aggiornamenti rapidi tra cittadino e operatori.</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="messages-container">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.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">
|
||||
{{ senderInitials(msg) }}
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'message-bubble-outgoing': isCurrentUser(msg.user_id),
|
||||
'message-bubble-incoming': !isCurrentUser(msg.user_id)
|
||||
}"
|
||||
class="message-bubble-content"
|
||||
>
|
||||
<div class="message-meta">
|
||||
<span v-if="!isCurrentUser(msg.user_id)" class="message-author">
|
||||
{{ msg.first_name }} {{ msg.last_name }}
|
||||
</span>
|
||||
<span v-else class="message-author">Tu</span>
|
||||
<span class="message-time">
|
||||
{{ formatDate(msg.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!isCurrentUser(msg.user_id)" class="small font-weight-bold mb-2 text-primary d-none">
|
||||
{{ msg.first_name }} {{ msg.last_name }}
|
||||
</div>
|
||||
|
||||
<div class="message-body">
|
||||
{{ msg.body }}
|
||||
</div>
|
||||
|
||||
<div v-if="canFlagMessage?.(msg)" class="message-actions">
|
||||
<button
|
||||
:disabled="isFlaggingMessageId === msg.id"
|
||||
class="btn btn-link btn-xs px-0"
|
||||
type="button"
|
||||
@click="emit('flag', msg)"
|
||||
>
|
||||
<span v-if="isFlaggingMessageId === msg.id" aria-hidden="true"
|
||||
class="spinner-border spinner-border-sm me-1"></span>
|
||||
<BIIcon v-else class="me-1" name="it-flag" size="xs"/>
|
||||
Segnala messaggio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
border: 1px solid #d9e3ee;
|
||||
border-radius: 4px;
|
||||
height: 34rem;
|
||||
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.message-empty-state {
|
||||
align-items: center;
|
||||
color: #5c6f82;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-row.is-current-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
align-items: center;
|
||||
background: #e6f0fa;
|
||||
border-radius: 999px;
|
||||
color: #17324d;
|
||||
display: inline-flex;
|
||||
flex: 0 0 2.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
height: 2.25rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.message-bubble-content {
|
||||
border-radius: 4px;
|
||||
max-width: min(100%, 40rem);
|
||||
padding: 0.875rem 1rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-bubble-incoming {
|
||||
background: #f5f7f9;
|
||||
border: 1px solid #d9e3ee;
|
||||
color: #17324d;
|
||||
}
|
||||
|
||||
.message-bubble-outgoing {
|
||||
background: #06c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: 0.8125rem;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.message-author {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
opacity: 0.78;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
margin-top: 0.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message-actions .btn {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.message-bubble-content {
|
||||
max-width: calc(100% - 3rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,111 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
currentPage: number
|
||||
perPage: number
|
||||
totalItems: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'page-change', page: number): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentPage: 1,
|
||||
perPage: 10,
|
||||
totalItems: 0
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const totalPages = computed(() => Math.ceil(props.totalItems / props.perPage))
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
emit('page-change', page)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const pageNumbers = computed(() => {
|
||||
const pages: (number | string)[] = []
|
||||
const maxVisible = 5
|
||||
|
||||
if (totalPages.value <= maxVisible) {
|
||||
for (let i = 1; i <= totalPages.value; i++) pages.push(i)
|
||||
} else {
|
||||
if (props.currentPage <= 3) {
|
||||
for (let i = 1; i <= maxVisible - 1; i++) pages.push(i)
|
||||
pages.push('...')
|
||||
pages.push(totalPages.value)
|
||||
} else if (props.currentPage >= totalPages.value - 2) {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = totalPages.value - (maxVisible - 2); i <= totalPages.value; i++) pages.push(i)
|
||||
} else {
|
||||
pages.push(1)
|
||||
pages.push('...')
|
||||
for (let i = props.currentPage - 1; i <= props.currentPage + 1; i++) pages.push(i)
|
||||
pages.push('...')
|
||||
pages.push(totalPages.value)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="totalPages > 1" class="d-flex justify-content-center mt-5 mb-5">
|
||||
<ul class="pagination mb-0">
|
||||
<li class="page-item" :class="{ disabled: currentPage === 1 }">
|
||||
<button
|
||||
class="page-link"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage === 1"
|
||||
aria-label="Pagina precedente"
|
||||
>
|
||||
<span aria-hidden="true">«</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li v-for="page in pageNumbers" :key="`page-${page}`" class="page-item" :class="{ active: page === currentPage }">
|
||||
<template v-if="page === '...'">
|
||||
<span class="page-link">...</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="page-link"
|
||||
@click="goToPage(page as number)"
|
||||
:aria-label="`Vai a pagina ${page}`"
|
||||
:aria-current="page === currentPage ? 'page' : undefined"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</template>
|
||||
</li>
|
||||
|
||||
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
|
||||
<button
|
||||
class="page-link"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage === totalPages"
|
||||
aria-label="Pagina successiva"
|
||||
>
|
||||
<span aria-hidden="true">»</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div v-if="totalPages > 1" class="text-center text-muted small mt-3">
|
||||
Pagina {{ currentPage }} di {{ totalPages }} ({{ totalItems }} elementi totali)
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pagination {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,684 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, CreateReportRequest } from '~/types/api'
|
||||
import type { LeafletMouseEvent, Map as LeafletMap, Marker as LeafletMarker } from 'leaflet'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
|
||||
import { reverseGeocode, searchAddresses } from '~/utils/geocoding'
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean
|
||||
categories?: Category[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', report: CreateReportRequest, media: File[]): void
|
||||
}
|
||||
|
||||
interface GeocodingSuggestion {
|
||||
label: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLoading: false,
|
||||
categories: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const form = ref({
|
||||
category_id: null as number | null,
|
||||
latitude: null as number | null,
|
||||
longitude: null as number | null,
|
||||
address: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const media = ref<File[]>([])
|
||||
const errors = ref<Record<string, string>>({})
|
||||
const successMessage = ref(false)
|
||||
const isGettingLocation = ref(false)
|
||||
const addressSuggestions = ref<GeocodingSuggestion[]>([])
|
||||
const isSearchingAddress = ref(false)
|
||||
const isResolvingAddress = ref(false)
|
||||
const addressSearchError = ref('')
|
||||
const showAddressSuggestions = ref(false)
|
||||
|
||||
const mapContainer = ref<HTMLElement | null>(null)
|
||||
let leaflet: typeof import('leaflet') | null = null
|
||||
let map: LeafletMap | null = null
|
||||
let marker: LeafletMarker | null = null
|
||||
let addressSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let manualLocationSyncTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let addressSearchRequestId = 0
|
||||
let reverseGeocodeRequestId = 0
|
||||
let lastSyncedLocationKey = ''
|
||||
let skipNextAddressSearch = false
|
||||
|
||||
const loadLeaflet = async () => {
|
||||
if (leaflet) return leaflet
|
||||
|
||||
const leafletModule = await import('leaflet')
|
||||
|
||||
const defaultIcon = leafletModule.icon({
|
||||
iconUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://unpkg.com/[email protected]/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41]
|
||||
})
|
||||
leafletModule.Marker.prototype.options.icon = defaultIcon
|
||||
|
||||
leaflet = leafletModule
|
||||
return leaflet
|
||||
}
|
||||
|
||||
const toLocationKey = (lat: number, lng: number) => `${lat.toFixed(6)}:${lng.toFixed(6)}`
|
||||
|
||||
const clearAddressSuggestions = () => {
|
||||
addressSuggestions.value = []
|
||||
showAddressSuggestions.value = false
|
||||
}
|
||||
|
||||
const cancelPendingAddressSearch = () => {
|
||||
addressSearchRequestId += 1
|
||||
isSearchingAddress.value = false
|
||||
|
||||
if (addressSearchTimeout) {
|
||||
clearTimeout(addressSearchTimeout)
|
||||
addressSearchTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPendingReverseGeocode = () => {
|
||||
reverseGeocodeRequestId += 1
|
||||
isResolvingAddress.value = false
|
||||
}
|
||||
|
||||
const setAddressValue = (address: string) => {
|
||||
cancelPendingAddressSearch()
|
||||
skipNextAddressSearch = true
|
||||
form.value.address = address
|
||||
|
||||
if (errors.value.address && address.trim()) {
|
||||
errors.value.address = ''
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAddressSuggestions = async (query: string) => {
|
||||
const requestId = ++addressSearchRequestId
|
||||
isSearchingAddress.value = true
|
||||
addressSearchError.value = ''
|
||||
|
||||
try {
|
||||
if (requestId !== addressSearchRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
const suggestions = await searchAddresses(query)
|
||||
|
||||
if (requestId !== addressSearchRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
addressSuggestions.value = suggestions
|
||||
showAddressSuggestions.value = suggestions.length > 0
|
||||
|
||||
if (!suggestions.length) {
|
||||
addressSearchError.value = 'Nessun indirizzo trovato nel Comune di Bari.'
|
||||
}
|
||||
} catch {
|
||||
if (requestId !== addressSearchRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
clearAddressSuggestions()
|
||||
addressSearchError.value = 'Ricerca indirizzi non disponibile al momento.'
|
||||
} finally {
|
||||
if (requestId === addressSearchRequestId) {
|
||||
isSearchingAddress.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolveAddressFromCoordinates = async (lat: number, lng: number, fallbackAddress = '') => {
|
||||
const requestId = ++reverseGeocodeRequestId
|
||||
isResolvingAddress.value = true
|
||||
addressSearchError.value = ''
|
||||
|
||||
try {
|
||||
if (requestId !== reverseGeocodeRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedAddress = (await reverseGeocode(lat, lng))?.trim() || fallbackAddress.trim()
|
||||
|
||||
if (resolvedAddress) {
|
||||
setAddressValue(resolvedAddress)
|
||||
clearAddressSuggestions()
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.value.address.trim()) {
|
||||
addressSearchError.value = 'Indirizzo non trovato per coordinate selezionate.'
|
||||
}
|
||||
} catch {
|
||||
if (requestId !== reverseGeocodeRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fallbackAddress.trim()) {
|
||||
setAddressValue(fallbackAddress.trim())
|
||||
clearAddressSuggestions()
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.value.address.trim()) {
|
||||
addressSearchError.value = 'Impossibile recuperare indirizzo da coordinate.'
|
||||
}
|
||||
} finally {
|
||||
if (requestId === reverseGeocodeRequestId) {
|
||||
isResolvingAddress.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddressFocus = () => {
|
||||
if (addressSuggestions.value.length > 0) {
|
||||
showAddressSuggestions.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddressBlur = () => {
|
||||
setTimeout(() => {
|
||||
showAddressSuggestions.value = false
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const updateLocation = async (
|
||||
lat: number,
|
||||
lng: number,
|
||||
options: {
|
||||
zoomTo?: boolean
|
||||
syncAddress?: boolean
|
||||
fallbackAddress?: string
|
||||
} = {}
|
||||
) => {
|
||||
const { zoomTo = false, syncAddress = true, fallbackAddress = '' } = options
|
||||
|
||||
if (!isWithinBariBounds(lat, lng)) {
|
||||
errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.'
|
||||
return
|
||||
}
|
||||
|
||||
const roundedLat = Number(lat.toFixed(6))
|
||||
const roundedLng = Number(lng.toFixed(6))
|
||||
|
||||
lastSyncedLocationKey = toLocationKey(roundedLat, roundedLng)
|
||||
form.value.latitude = roundedLat
|
||||
form.value.longitude = roundedLng
|
||||
|
||||
if (map && leaflet) {
|
||||
if (!marker) {
|
||||
marker = leaflet.marker([roundedLat, roundedLng]).addTo(map)
|
||||
} else {
|
||||
marker.setLatLng([roundedLat, roundedLng])
|
||||
}
|
||||
if (zoomTo) {
|
||||
map.setView([roundedLat, roundedLng], 16)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.value.location) {
|
||||
errors.value.location = ''
|
||||
}
|
||||
|
||||
if (syncAddress) {
|
||||
await resolveAddressFromCoordinates(roundedLat, roundedLng, fallbackAddress)
|
||||
} else if (fallbackAddress.trim()) {
|
||||
setAddressValue(fallbackAddress.trim())
|
||||
}
|
||||
}
|
||||
|
||||
const initMap = async () => {
|
||||
if (!import.meta.client || !mapContainer.value || map) return
|
||||
|
||||
const L = await loadLeaflet()
|
||||
if (!mapContainer.value || map) return
|
||||
|
||||
const bariBounds = L.latLngBounds(
|
||||
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||
)
|
||||
|
||||
map = L.map(mapContainer.value, {
|
||||
maxBounds: bariBounds,
|
||||
maxBoundsViscosity: 1,
|
||||
minZoom: 12
|
||||
}).setView(BARI_CENTER, 12)
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map)
|
||||
map.fitBounds(bariBounds)
|
||||
|
||||
map.on('click', (e: LeafletMouseEvent) => {
|
||||
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
|
||||
errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.'
|
||||
return
|
||||
}
|
||||
|
||||
void updateLocation(e.latlng.lat, e.latlng.lng)
|
||||
})
|
||||
|
||||
if (form.value.latitude && form.value.longitude) {
|
||||
void updateLocation(form.value.latitude, form.value.longitude, { zoomTo: true })
|
||||
}
|
||||
}
|
||||
|
||||
const selectAddressSuggestion = async (suggestion: GeocodingSuggestion) => {
|
||||
addressSearchError.value = ''
|
||||
clearAddressSuggestions()
|
||||
setAddressValue(suggestion.label)
|
||||
await updateLocation(suggestion.latitude, suggestion.longitude, {
|
||||
zoomTo: true,
|
||||
syncAddress: false
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddressEnter = async () => {
|
||||
if (addressSuggestions.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await selectAddressSuggestion(addressSuggestions.value[0])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void initMap()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelPendingAddressSearch()
|
||||
cancelPendingReverseGeocode()
|
||||
|
||||
if (manualLocationSyncTimeout) {
|
||||
clearTimeout(manualLocationSyncTimeout)
|
||||
manualLocationSyncTimeout = null
|
||||
}
|
||||
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
})
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
errors.value = {}
|
||||
|
||||
if (!form.value.category_id) {
|
||||
errors.value.category_id = 'Seleziona una categoria'
|
||||
}
|
||||
if (!form.value.latitude || !form.value.longitude) {
|
||||
errors.value.location = 'Imposta la posizione sulla mappa'
|
||||
} else if (!isWithinBariBounds(form.value.latitude, form.value.longitude)) {
|
||||
errors.value.location = 'La posizione deve essere nel Comune di Bari'
|
||||
}
|
||||
if (!form.value.address?.trim()) {
|
||||
errors.value.address = 'Inserisci l\'indirizzo'
|
||||
}
|
||||
if (!form.value.description?.trim() || form.value.description.length < 10) {
|
||||
errors.value.description = 'Descrivi il problema (minimo 10 caratteri)'
|
||||
}
|
||||
|
||||
return Object.keys(errors.value).length === 0
|
||||
}
|
||||
|
||||
const getGeolocation = async () => {
|
||||
if (!navigator.geolocation) {
|
||||
errors.value.location = 'Geolocalizzazione non disponibile nel tuo browser'
|
||||
return
|
||||
}
|
||||
|
||||
isGettingLocation.value = true
|
||||
errors.value.location = ''
|
||||
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject)
|
||||
})
|
||||
|
||||
await updateLocation(position.coords.latitude, position.coords.longitude, { zoomTo: true })
|
||||
} catch (error: any) {
|
||||
errors.value.location = 'Impossibile ottenere la posizione. Clicca sulla mappa per inserirla manualmente.'
|
||||
} finally {
|
||||
isGettingLocation.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
const reportData: CreateReportRequest = {
|
||||
category_id: form.value.category_id!,
|
||||
latitude: form.value.latitude!,
|
||||
longitude: form.value.longitude!,
|
||||
address: form.value.address,
|
||||
description: form.value.description,
|
||||
}
|
||||
|
||||
emit('submit', reportData, media.value)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
category_id: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
address: '',
|
||||
description: '',
|
||||
}
|
||||
media.value = []
|
||||
errors.value = {}
|
||||
addressSearchError.value = ''
|
||||
clearAddressSuggestions()
|
||||
cancelPendingAddressSearch()
|
||||
cancelPendingReverseGeocode()
|
||||
lastSyncedLocationKey = ''
|
||||
skipNextAddressSearch = false
|
||||
|
||||
if (marker && map) {
|
||||
map.removeLayer(marker)
|
||||
marker = null
|
||||
map.setView(BARI_CENTER, 12)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => form.value.category_id, () => {
|
||||
if (errors.value.category_id) {
|
||||
errors.value.category_id = ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => form.value.address, (newAddress) => {
|
||||
if (skipNextAddressSearch) {
|
||||
skipNextAddressSearch = false
|
||||
return
|
||||
}
|
||||
|
||||
cancelPendingReverseGeocode()
|
||||
|
||||
if (errors.value.address && newAddress.trim()) {
|
||||
errors.value.address = ''
|
||||
}
|
||||
|
||||
if (addressSearchTimeout) {
|
||||
clearTimeout(addressSearchTimeout)
|
||||
addressSearchTimeout = null
|
||||
}
|
||||
|
||||
if (newAddress.trim().length < 3) {
|
||||
isSearchingAddress.value = false
|
||||
addressSearchError.value = ''
|
||||
clearAddressSuggestions()
|
||||
return
|
||||
}
|
||||
|
||||
addressSearchTimeout = setTimeout(() => {
|
||||
void fetchAddressSuggestions(newAddress.trim())
|
||||
}, 350)
|
||||
})
|
||||
|
||||
watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => {
|
||||
if (newLat && newLng && map && leaflet) {
|
||||
if (!marker || marker.getLatLng().lat !== newLat || marker.getLatLng().lng !== newLng) {
|
||||
if (!marker) {
|
||||
marker = leaflet.marker([newLat, newLng]).addTo(map)
|
||||
} else {
|
||||
marker.setLatLng([newLat, newLng])
|
||||
}
|
||||
map.setView([newLat, newLng], 16)
|
||||
}
|
||||
}
|
||||
|
||||
if (!newLat || !newLng) {
|
||||
return
|
||||
}
|
||||
|
||||
const locationKey = toLocationKey(newLat, newLng)
|
||||
if (locationKey === lastSyncedLocationKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (manualLocationSyncTimeout) {
|
||||
clearTimeout(manualLocationSyncTimeout)
|
||||
}
|
||||
|
||||
manualLocationSyncTimeout = setTimeout(() => {
|
||||
void updateLocation(newLat, newLng, { zoomTo: true })
|
||||
}, 500)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="report-form">
|
||||
<div v-if="successMessage" class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
Segnalazione creata con successo! Reindirizzamento...
|
||||
<button type="button" class="btn-close" @click="successMessage = false"></button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="mb-4">
|
||||
<label for="category" class="form-label fw-bold">
|
||||
<BIIcon name="it-list" size="sm" color="primary" class="me-2" />
|
||||
Categoria del problema *
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
v-model.number="form.category_id"
|
||||
class="form-select"
|
||||
:class="{ 'is-invalid': errors.category_id }"
|
||||
required
|
||||
>
|
||||
<option :value="null">-- Seleziona una categoria --</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="errors.category_id" class="invalid-feedback">
|
||||
{{ errors.category_id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">
|
||||
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
|
||||
Posizione *
|
||||
</label>
|
||||
<p class="small text-muted mb-2">Le segnalazioni sono abilitate solo entro confini del Comune di Bari.</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary w-100 mb-3"
|
||||
@click="getGeolocation"
|
||||
:disabled="isGettingLocation"
|
||||
>
|
||||
<span v-if="isGettingLocation" class="spinner-border spinner-border-sm me-2"></span>
|
||||
<BIIcon v-else name="it-location" size="sm" class="me-2" />
|
||||
{{ isGettingLocation ? 'Localizzazione in corso...' : 'Usa posizione attuale' }}
|
||||
</button>
|
||||
|
||||
<ClientOnly>
|
||||
<div
|
||||
ref="mapContainer"
|
||||
class="map-container mb-3 border rounded"
|
||||
:class="{ 'border-danger': errors.location }"
|
||||
@vue:mounted="initMap"
|
||||
></div>
|
||||
</ClientOnly>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<input
|
||||
v-model.number="form.latitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
class="form-control form-control-sm"
|
||||
:class="{ 'is-invalid': errors.location }"
|
||||
placeholder="Latitudine (es. 45.5)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<input
|
||||
v-model.number="form.longitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
class="form-control form-control-sm"
|
||||
:class="{ 'is-invalid': errors.location }"
|
||||
placeholder="Longitudine (es. 10.2)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errors.location" class="invalid-feedback d-block mt-0 mb-3">
|
||||
{{ errors.location }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="address" class="form-label fw-bold">
|
||||
<BIIcon name="it-home" size="sm" color="primary" class="me-2" />
|
||||
Indirizzo *
|
||||
</label>
|
||||
<div class="position-relative">
|
||||
<input
|
||||
id="address"
|
||||
v-model="form.address"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': errors.address }"
|
||||
placeholder="Es. Via Sparano 12, Bari"
|
||||
autocomplete="street-address"
|
||||
required
|
||||
@focus="handleAddressFocus"
|
||||
@blur="handleAddressBlur"
|
||||
@keydown.enter.prevent="handleAddressEnter"
|
||||
/>
|
||||
<div
|
||||
v-if="showAddressSuggestions && addressSuggestions.length > 0"
|
||||
class="list-group position-absolute w-100 shadow-sm rounded-3 overflow-hidden address-suggestions"
|
||||
>
|
||||
<button
|
||||
v-for="suggestion in addressSuggestions"
|
||||
:key="`${suggestion.latitude}-${suggestion.longitude}-${suggestion.label}`"
|
||||
type="button"
|
||||
class="list-group-item list-group-item-action text-start"
|
||||
@mousedown.prevent="selectAddressSuggestion(suggestion)"
|
||||
>
|
||||
{{ suggestion.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSearchingAddress" class="form-text">
|
||||
Ricerca indirizzi in corso...
|
||||
</div>
|
||||
<div v-else-if="isResolvingAddress" class="form-text">
|
||||
Recupero indirizzo da coordinate...
|
||||
</div>
|
||||
<div v-else-if="addressSearchError" class="form-text text-muted">
|
||||
{{ addressSearchError }}
|
||||
</div>
|
||||
<div v-if="errors.address" class="invalid-feedback">
|
||||
{{ errors.address }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="description" class="form-label fw-bold">
|
||||
<BIIcon name="it-write" size="sm" color="primary" class="me-2" />
|
||||
Descrizione del problema *
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': errors.description }"
|
||||
rows="5"
|
||||
placeholder="Descrivi dettagliatamente il problema (tipo buca stradale, pericolo, ecc.)"
|
||||
required
|
||||
/>
|
||||
<small class="form-text text-muted d-block mt-1">
|
||||
{{ form.description.length }} caratteri (minimo 10)
|
||||
</small>
|
||||
<div v-if="errors.description" class="invalid-feedback d-block">
|
||||
{{ errors.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">
|
||||
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
|
||||
Allega foto/video (opzionale)
|
||||
</label>
|
||||
<MediaUploader v-model="media" :max-files="5" :max-file-size="50" />
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-between">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
@click="resetForm"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-lg"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
|
||||
{{ isLoading ? 'Invio segnalazione...' : 'Invia segnalazione' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.report-form {
|
||||
background-color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.address-suggestions {
|
||||
top: calc(100% + 0.25rem);
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,94 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Report } from '~/types/api'
|
||||
|
||||
type ActionVariant = 'open' | 'manage'
|
||||
|
||||
interface Props {
|
||||
reports: Report[]
|
||||
detailBasePath?: string
|
||||
actionVariant?: ActionVariant
|
||||
actionLabel?: string
|
||||
showAssignee?: boolean
|
||||
processingIds?: number[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
detailBasePath: '/reports',
|
||||
actionVariant: 'open',
|
||||
actionLabel: 'Open report',
|
||||
showAssignee: false,
|
||||
processingIds: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'take', id: number): void
|
||||
}>()
|
||||
|
||||
const formatDate = (dateString?: string): string => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleDateString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const reportHref = (report: Report) => `${props.detailBasePath}/${report.id}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table-responsive report-table-wrapper">
|
||||
<table class="table table-hover report-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Categoria</th>
|
||||
<th scope="col">Indirizzo</th>
|
||||
<th scope="col">Stato</th>
|
||||
<th v-if="showAssignee" scope="col">Operatore</th>
|
||||
<th scope="col">Creazione</th>
|
||||
<th scope="col" class="text-end">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="report in reports" :key="report.id" class="align-middle">
|
||||
<td class="report-table-id">#{{ report.id }}</td>
|
||||
<td class="text-nowrap">{{ report.category_name || '-' }}</td>
|
||||
<td>
|
||||
<span class="report-address d-inline-block text-truncate align-middle" style="max-width: 10ch;" :title="report.address">
|
||||
{{ report.address || '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge :status="report.status" />
|
||||
</td>
|
||||
<td v-if="showAssignee" class="text-muted">
|
||||
{{ report.assigned_operator_id ? `#${report.assigned_operator_id}` : '-' }}
|
||||
</td>
|
||||
<td class="text-muted text-nowrap">{{ formatDate(report.created_at) }}</td>
|
||||
<td class="text-end">
|
||||
<button
|
||||
v-if="actionVariant === 'manage'"
|
||||
type="button"
|
||||
class="btn btn-primary btn-xs"
|
||||
:disabled="processingIds.includes(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
|
||||
</button>
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="reportHref(report)"
|
||||
class="btn btn-outline-primary btn-xs"
|
||||
:aria-label="actionLabel"
|
||||
:title="actionLabel"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</NuxtLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ReportStatus } from '~/types/api'
|
||||
|
||||
interface Props {
|
||||
status: ReportStatus
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const badgeConfig = computed(() => {
|
||||
const config: Record<ReportStatus, { color: string, text: string, icon: string }> = {
|
||||
OPEN: { color: 'info', text: 'Aperta', icon: 'it-info-circle' },
|
||||
UNDER_REVIEW: { color: 'warning', text: 'In Revisione', icon: 'it-clock' },
|
||||
ASSIGNED: { color: 'primary', text: 'Assegnata', icon: 'it-user' },
|
||||
IN_PROGRESS: { color: 'primary', text: 'In Lavorazione', icon: 'it-settings' },
|
||||
RESOLVED: { color: 'success', text: 'Risolta', icon: 'it-check-circle' },
|
||||
REJECTED: { color: 'danger', text: 'Rifiutata', icon: 'it-close-circle' },
|
||||
CLOSED: { color: 'secondary', text: 'Conclusa', icon: 'it-locked' }
|
||||
}
|
||||
|
||||
return config[props.status] || { color: 'secondary', text: props.status, icon: 'it-info-circle' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="badge badge-pill rounded-pill d-inline-flex align-items-center gap-1" :class="`bg-${badgeConfig.color}`">
|
||||
<BIIcon :name="badgeConfig.icon" size="xs" color="white" />
|
||||
{{ badgeConfig.text }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge {
|
||||
font-weight: 600;
|
||||
padding: 0.35em 0.65em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { getOverviewStats } = useAdmin()
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="card card-bg">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h5 card-title mb-0">Gestione sistema</h2>
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div
|
||||
v-for="action in adminActions"
|
||||
:key="action.to"
|
||||
class="col-12 col-md-6 col-xl-4"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="action.to"
|
||||
class="btn btn-sm w-100 d-flex align-items-center justify-content-start gap-2 admin-action"
|
||||
:class="action.tone === 'danger' ? 'btn-outline-danger' : 'btn-outline-primary'"
|
||||
>
|
||||
<BIIcon :name="action.icon" size="sm" :color="action.tone === 'danger' ? 'danger' : 'primary'" />
|
||||
<span>{{ action.label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-action {
|
||||
min-height: 44px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -1,93 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Report } from '~/types/api'
|
||||
|
||||
const { user } = useAuth()
|
||||
const { getReports } = useReports()
|
||||
|
||||
const { data: reportsRes, pending } = useAsyncData('citizen-dashboard-reports', () => getReports({}))
|
||||
|
||||
const reports = computed<Report[]>(() => reportsRes.value?.reports || [])
|
||||
const recentReports = computed(() => reports.value.slice(0, 4))
|
||||
|
||||
const stats = computed(() => {
|
||||
return reports.value.reduce(
|
||||
(acc, report) => {
|
||||
acc.total++
|
||||
if (report.status === 'OPEN') acc.open++
|
||||
if (report.status === 'IN_PROGRESS' || report.status === 'ASSIGNED') acc.inProgress++
|
||||
if (report.status === 'RESOLVED' || report.status === 'CLOSED') acc.resolved++
|
||||
return acc
|
||||
},
|
||||
{ total: 0, open: 0, inProgress: 0, resolved: 0 }
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="card card-bg dashboard-stat h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
<div class="small text-muted">Segnalazioni</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="card card-bg dashboard-stat h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="stat-value">{{ stats.open }}</div>
|
||||
<div class="small text-muted">Aperte</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="card card-bg dashboard-stat h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="stat-value">{{ stats.inProgress }}</div>
|
||||
<div class="small text-muted">In corso</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="card card-bg dashboard-stat h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="stat-value">{{ user?.points || 0 }}</div>
|
||||
<div class="small text-muted">Punti cittadino</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<NuxtLink to="/reports/new" class="btn btn-primary">
|
||||
<BIIcon name="it-plus-circle" size="sm" color="white" class="me-2" />
|
||||
Nuova segnalazione
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/reports" class="btn btn-outline-primary">
|
||||
<BIIcon name="it-list" size="sm" class="me-2" />
|
||||
Le mie segnalazioni
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="card card-bg">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h5 card-title mb-0">Aggiornamenti recenti</h2>
|
||||
<NuxtLink to="/reports" class="btn btn-link btn-sm pe-0">Vedi tutte</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Caricamento...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="recentReports.length === 0" class="alert alert-info mb-0" role="alert">
|
||||
Non hai ancora creato segnalazioni.
|
||||
</div>
|
||||
<ReportTable v-else :reports="recentReports" action-label="Apri segnalazione" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,50 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { getOpenReports, getAssignedReports } = useOperator()
|
||||
|
||||
const { data: openReportsRes } = useAsyncData('open-reports', () => getOpenReports({ per_page: 5 }))
|
||||
const { data: assignedReportsRes } = useAsyncData('assigned-reports', () => getAssignedReports({ per_page: 5 }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-6 mb-4">
|
||||
<div class="card card-bg h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="h5 mb-0">Segnalazioni disponibili</h3>
|
||||
<NuxtLink to="/operator" class="btn btn-link btn-sm pe-0">Apri elenco</NuxtLink>
|
||||
</div>
|
||||
<div v-if="openReportsRes?.ok && openReportsRes.reports.length === 0" class="alert alert-info mb-0">
|
||||
Nessuna nuova segnalazione in ingresso.
|
||||
</div>
|
||||
<ReportTable
|
||||
v-else
|
||||
:reports="openReportsRes?.reports || []"
|
||||
detail-base-path="/operator/reports"
|
||||
action-label="Visualizza"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6 mb-4">
|
||||
<div class="card card-bg h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="h5 mb-0">Le mie assegnazioni</h3>
|
||||
<NuxtLink to="/operator/assigned" class="btn btn-link btn-sm pe-0">Vedi tutte</NuxtLink>
|
||||
</div>
|
||||
<div v-if="assignedReportsRes?.ok && assignedReportsRes.reports.length === 0" class="alert alert-info mb-0">
|
||||
Non hai segnalazioni in carico.
|
||||
</div>
|
||||
<ReportTable
|
||||
v-else
|
||||
:reports="assignedReportsRes?.reports || []"
|
||||
detail-base-path="/operator/reports"
|
||||
action-label="Apri segnalazione"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,18 +1,9 @@
|
||||
import type {
|
||||
AreaListResponse,
|
||||
CategoryListResponse,
|
||||
CreateAreaRequest,
|
||||
CreateCategoryRequest,
|
||||
CreateTeamRequest,
|
||||
AreaListResponse, CategoryListResponse, CreateAreaRequest, CreateCategoryRequest, CreateTeamRequest,
|
||||
ModerationListResponse,
|
||||
OkResponse,
|
||||
OverviewStatsResponse,
|
||||
UpdateAreaRequest,
|
||||
UpdateCategoryRequest,
|
||||
UpdateModerationRequest,
|
||||
UpdateTeamRequest,
|
||||
UpdateUserBanRequest,
|
||||
UpdateUserRolesRequest,
|
||||
OverviewStatsResponse, TeamListResponse, UpdateModerationRequest,
|
||||
UpdateUserBanRequest, UpdateUserRolesRequest,
|
||||
UserListResponse
|
||||
} from "~/types/api";
|
||||
|
||||
@@ -30,37 +21,22 @@ export const useAdmin = () => {
|
||||
useApiFetch<OkResponse>(`/api/v1/admin/users/${id}/roles`, { method: 'PATCH', body })
|
||||
|
||||
const getAreas = () =>
|
||||
useApiFetch<AreaListResponse>('/api/v1/areas', { method: 'GET' })
|
||||
useApiFetch<AreaListResponse>('/api/v1/admin/areas', { method: 'GET' })
|
||||
|
||||
const createArea = (body: CreateAreaRequest) =>
|
||||
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 = () =>
|
||||
useApiFetch<CategoryListResponse>('/api/v1/admin/categories', { method: 'GET' })
|
||||
|
||||
const createCategory = (body: CreateCategoryRequest) =>
|
||||
useApiFetch<OkResponse>('/api/v1/admin/categories', { method: 'POST', body })
|
||||
|
||||
const updateCategory = (id: number, body: UpdateCategoryRequest) =>
|
||||
useApiFetch<OkResponse>(`/api/v1/admin/categories/${id}`, { method: 'PATCH', body })
|
||||
|
||||
const deleteCategory = (id: number) =>
|
||||
useApiFetch<OkResponse>(`/api/v1/admin/categories/${id}`, { method: 'DELETE' })
|
||||
const getTeams = () =>
|
||||
useApiFetch<TeamListResponse>('/api/v1/admin/teams', { method: 'GET' })
|
||||
|
||||
const createTeam = (body: CreateTeamRequest) =>
|
||||
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 }) =>
|
||||
useApiFetch<ModerationListResponse>('/api/v1/admin/moderation', { method: 'GET', query })
|
||||
@@ -75,16 +51,11 @@ export const useAdmin = () => {
|
||||
updateUserRoles,
|
||||
getAreas,
|
||||
createArea,
|
||||
updateArea,
|
||||
deleteArea,
|
||||
getCategories,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
getTeams,
|
||||
createTeam,
|
||||
updateTeam,
|
||||
deleteTeam,
|
||||
getModerationFlags,
|
||||
updateModerationFlag,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,34 +2,14 @@ import { defu } from 'defu'
|
||||
|
||||
export const useApiFetch = <T>(request: string, options: Parameters<typeof $fetch>[1] = {}) => {
|
||||
const config = useRuntimeConfig()
|
||||
const nuxtApp = useNuxtApp()
|
||||
const tokenCookie = useCookie('auth_token', { path: '/' })
|
||||
const tokenState = useState<string | null>('auth_token_state')
|
||||
|
||||
const tokenValue = tokenState.value ?? tokenCookie.value
|
||||
const token = useCookie('auth_token') // Make sure this matches the cookie name you use on login
|
||||
|
||||
const defaults: Parameters<typeof $fetch>[1] = {
|
||||
baseURL: config.public.api as string,
|
||||
headers: tokenValue ? { Authorization: `Bearer ${tokenValue}` } : {},
|
||||
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {},
|
||||
}
|
||||
|
||||
const params = defu(options, defaults)
|
||||
|
||||
try {
|
||||
if (params?.body && typeof FormData !== 'undefined' && params.body instanceof FormData) {
|
||||
if (params.headers && 'Content-Type' in params.headers) {
|
||||
delete params.headers['Content-Type']
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return $fetch<T>(request, params).catch((err: any) => {
|
||||
const status = err?.statusCode || err?.status || err?.response?.status
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
console.error(`[API] Authentication error (${status})`)
|
||||
}
|
||||
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return $fetch<T>(request, params)
|
||||
}
|
||||
@@ -1,110 +1,13 @@
|
||||
import type {
|
||||
CieSessionResponse,
|
||||
CieStartResponse,
|
||||
CurrentUserResponse,
|
||||
EmailRequest,
|
||||
NotificationListResponse,
|
||||
ChangePasswordRequest,
|
||||
CurrentUserResponse, EmailRequest, NotificationListResponse,
|
||||
OkResponse,
|
||||
OtpRequestResponse,
|
||||
RegisterCitizenRequest,
|
||||
RegisterCitizenResponse,
|
||||
RewardsResponse,
|
||||
TokenResponse,
|
||||
UpdateProfileRequest,
|
||||
VerifyOtpRequest
|
||||
OtpRequestResponse, PasswordLoginRequest, RegisterCitizenRequest,
|
||||
RegisterCitizenResponse, RewardsResponse,
|
||||
TokenResponse, VerifyOtpRequest
|
||||
} from "~/types/api";
|
||||
|
||||
export const useAuth = () => {
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
const token = useCookie<string | null>('auth_token', {
|
||||
maxAge: 60 * 60 * 12,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
watch: true
|
||||
})
|
||||
|
||||
const tokenState = useState<string | null>('auth_token_state', () => token.value ?? null)
|
||||
|
||||
type UserContext = CurrentUserResponse['user']
|
||||
const user = useState<UserContext | null>('auth_user', () => null)
|
||||
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 hasUnreadNotifications = computed(() => unreadNotifications.value > 0)
|
||||
const isAuthStatusError = (error: any) => {
|
||||
const status = error?.statusCode || error?.status || error?.response?.status
|
||||
return status === 401 || status === 403
|
||||
}
|
||||
|
||||
watch(() => token.value, (newVal) => {
|
||||
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 { redirectOnSuccess = false, currentPath } = options
|
||||
const tokenVal = tokenState.value || token.value
|
||||
if (!tokenVal || isSyncingUser.value) return
|
||||
|
||||
isSyncingUser.value = true
|
||||
try {
|
||||
const res = await fetchCurrentUser()
|
||||
if (res.ok) {
|
||||
user.value = res.user
|
||||
await nuxtApp.runWithContext(() => refreshUnreadNotifications())
|
||||
const path = currentPath ?? nuxtApp.$router.currentRoute.value.path
|
||||
|
||||
if (redirectOnSuccess && path.startsWith('/auth')) {
|
||||
await nuxtApp.runWithContext(() => navigateTo('/dashboard'))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAuthStatusError(error)) {
|
||||
console.error('[Auth] Sync failed with authentication error, keeping session state:', error)
|
||||
return
|
||||
}
|
||||
|
||||
console.error('[Auth] Sync failed, logging out:', error)
|
||||
logout()
|
||||
} finally {
|
||||
isSyncingUser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginSuccess = async (jwtToken: string) => {
|
||||
token.value = jwtToken
|
||||
tokenState.value = jwtToken
|
||||
await syncUser()
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
token.value = null
|
||||
tokenState.value = null
|
||||
user.value = null
|
||||
unreadNotifications.value = 0
|
||||
nuxtApp.runWithContext(() => navigateTo('/auth/login'))
|
||||
}
|
||||
|
||||
const register = (body: RegisterCitizenRequest) =>
|
||||
useApiFetch<RegisterCitizenResponse>('/api/v1/auth/register', { method: 'POST', body })
|
||||
|
||||
@@ -114,17 +17,11 @@ export const useAuth = () => {
|
||||
const verifyOtp = (body: VerifyOtpRequest) =>
|
||||
useApiFetch<TokenResponse>('/api/v1/auth/otp/verify', { method: 'POST', body })
|
||||
|
||||
const startCieLogin = (frontendRedirectUri: string) =>
|
||||
useApiFetch<CieStartResponse>('/api/v1/auth/cie/start', {
|
||||
method: 'POST',
|
||||
body: { frontend_redirect_uri: frontendRedirectUri }
|
||||
})
|
||||
const loginWithPassword = (body: PasswordLoginRequest) =>
|
||||
useApiFetch<TokenResponse>('/api/v1/auth/login/password', { method: 'POST', body })
|
||||
|
||||
const fetchCieSession = (id: string) =>
|
||||
useApiFetch<CieSessionResponse>(`/api/v1/auth/cie/session/${id}`, { method: 'GET' })
|
||||
|
||||
const updateProfile = (body: UpdateProfileRequest) =>
|
||||
useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'PATCH', body })
|
||||
const changePassword = (body: ChangePasswordRequest) =>
|
||||
useApiFetch<OkResponse>('/api/v1/auth/change-password', { method: 'POST', body })
|
||||
|
||||
const fetchCurrentUser = () =>
|
||||
useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'GET' })
|
||||
@@ -139,27 +36,14 @@ export const useAuth = () => {
|
||||
useApiFetch<OkResponse>(`/api/v1/notifications/${id}/read`, { method: 'PATCH' })
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
tokenState,
|
||||
isSyncingUser,
|
||||
isAuthenticated,
|
||||
unreadNotifications,
|
||||
hasUnreadNotifications,
|
||||
refreshUnreadNotifications,
|
||||
handleLoginSuccess,
|
||||
syncUser,
|
||||
logout,
|
||||
|
||||
register,
|
||||
requestOtp,
|
||||
verifyOtp,
|
||||
startCieLogin,
|
||||
fetchCieSession,
|
||||
updateProfile,
|
||||
loginWithPassword,
|
||||
changePassword,
|
||||
fetchCurrentUser,
|
||||
fetchRewards,
|
||||
fetchNotifications,
|
||||
markNotificationRead,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ export interface Member {
|
||||
|
||||
export function useMembers() {
|
||||
const members: Member[] = [
|
||||
{ name: "Renato Loiacono", image: "/renatoloiacono.webp" },
|
||||
{ name: "Michelangelo Parisi", image: "/michelangeloparisi.webp" },
|
||||
{ name: "Paolo Gabriele Schiraldi", image: "/paologabrieleschiraldi.webp" },
|
||||
{ name: "Renato Loiacono", image: "https://placehold.co/50x50" },
|
||||
{ name: "Michelangelo Parisi", image: "https://placehold.co/50x50" },
|
||||
{ name: "Gabriele Schiraldi", image: "https://placehold.co/50x50" },
|
||||
];
|
||||
|
||||
const sortedMembers = computed(() => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type {
|
||||
AssignmentLinkResponse,
|
||||
AssignTeamRequest,
|
||||
InterventionSummaryResponse,
|
||||
OkResponse,
|
||||
PagedReportListResponse,
|
||||
TeamListResponse,
|
||||
UpdateStatusRequest
|
||||
} from "~/types/api";
|
||||
|
||||
@@ -18,9 +16,6 @@ export const useOperator = () => {
|
||||
const getOtherOperatorReports = (query?: { page?: number; per_page?: number }) =>
|
||||
useApiFetch<PagedReportListResponse>('/api/v1/operator/reports/others', { method: 'GET', query })
|
||||
|
||||
const getTeams = () =>
|
||||
useApiFetch<TeamListResponse>('/api/v1/operator/teams', { method: 'GET' })
|
||||
|
||||
const takeReport = (id: number) =>
|
||||
useApiFetch<OkResponse>(`/api/v1/reports/${id}/take`, { method: 'POST' })
|
||||
|
||||
@@ -33,26 +28,13 @@ export const useOperator = () => {
|
||||
const getInterventionSummary = (id: number) =>
|
||||
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 {
|
||||
getOpenReports,
|
||||
getAssignedReports,
|
||||
getOtherOperatorReports,
|
||||
getTeams,
|
||||
takeReport,
|
||||
updateReportStatus,
|
||||
assignTeam,
|
||||
getInterventionSummary,
|
||||
createInterventionLink,
|
||||
revokeInterventionLink,
|
||||
getPublicIntervention,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,22 @@
|
||||
import type {
|
||||
CategoryListResponse,
|
||||
CreateReportRequest,
|
||||
CreateReportResponse,
|
||||
FeedbackRequest,
|
||||
FlagRequest,
|
||||
FlagResponse,
|
||||
CreateReportResponse, FeedbackRequest, FlagRequest, FlagResponse,
|
||||
MediaUploadResponse,
|
||||
MessageListResponse,
|
||||
MessageRequest,
|
||||
OkResponse,
|
||||
MessageListResponse, MessageRequest, OkResponse,
|
||||
ReportDetailResponse,
|
||||
ReportListResponse,
|
||||
TeamListResponse
|
||||
ReportListResponse
|
||||
} from "~/types/api";
|
||||
|
||||
export const useReports = () => {
|
||||
const getReports = (query?: { area_id?: number; category_id?: number; status?: string; page?: number; per_page?: number }) =>
|
||||
const getReports = (query?: { area_id?: number; category_id?: number; status?: string }) =>
|
||||
useApiFetch<ReportListResponse>('/api/v1/reports', { method: 'GET', query })
|
||||
|
||||
const getCategories = () =>
|
||||
useApiFetch<CategoryListResponse>('/api/v1/report-categories', { method: 'GET' })
|
||||
|
||||
const createReport = (body: CreateReportRequest) =>
|
||||
useApiFetch<CreateReportResponse>('/api/v1/reports', { method: 'POST', body })
|
||||
|
||||
const getReportDetail = (id: number) =>
|
||||
useApiFetch<ReportDetailResponse>(`/api/v1/reports/${id}`, { method: 'GET' })
|
||||
|
||||
|
||||
const uploadMedia = (id: number, formData: FormData) =>
|
||||
useApiFetch<MediaUploadResponse>(`/api/v1/reports/${id}/media`, { method: 'POST', body: formData })
|
||||
|
||||
@@ -45,12 +35,8 @@ export const useReports = () => {
|
||||
const flagMessage = (reportId: number, messageId: number, body: FlagRequest) =>
|
||||
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 {
|
||||
getReports,
|
||||
getCategories,
|
||||
createReport,
|
||||
getReportDetail,
|
||||
uploadMedia,
|
||||
@@ -59,6 +45,5 @@ export const useReports = () => {
|
||||
addFeedback,
|
||||
flagReport,
|
||||
flagMessage,
|
||||
getTeamSuggestions,
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import Footer from "~/components/Footer.vue";
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<div class="italian-flag"></div>
|
||||
<Footer />
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,19 +22,6 @@ import Footer from "~/components/Footer.vue";
|
||||
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 {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { checkRoleAccess, type MetaRoleRequirement } from '~/utils/roles'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuth()
|
||||
|
||||
if ((auth.token.value || auth.tokenState.value) && !auth.user.value && !auth.isSyncingUser.value) {
|
||||
try {
|
||||
await auth.syncUser({ currentPath: to.path })
|
||||
} catch (error) {
|
||||
console.error('[Auth Middleware] Sync failed:', error)
|
||||
return navigateTo('/auth/login')
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated.value) {
|
||||
return navigateTo('/auth/login')
|
||||
}
|
||||
|
||||
const requiredRole = to.meta.auth?.role as MetaRoleRequirement | undefined
|
||||
|
||||
if (requiredRole && auth.user.value) {
|
||||
const userRole = auth.user.value.role
|
||||
|
||||
if (!checkRoleAccess(userRole, requiredRole)) {
|
||||
console.warn(`[Auth] User role '${userRole}' rejected for route requirement:`, requiredRole)
|
||||
return navigateTo('/')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuth()
|
||||
|
||||
if ((auth.token.value || auth.tokenState.value) && !auth.user.value && !auth.isSyncingUser.value) {
|
||||
try {
|
||||
await auth.syncUser({ currentPath: to.path })
|
||||
} catch (error) {
|
||||
console.error('[Guest Middleware] Sync failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated.value && auth.user.value) {
|
||||
return navigateTo('/dashboard')
|
||||
}
|
||||
})
|
||||
@@ -1,547 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
auth: { role: 'ADMIN' }
|
||||
})
|
||||
|
||||
useHead({ title: 'Aree - Admin' })
|
||||
|
||||
const { getAreas, createArea, updateArea, deleteArea } = useAdmin()
|
||||
const { data: areasRes, pending, refresh } = useAsyncData('admin-areas', () => getAreas())
|
||||
|
||||
const isCreating = ref(false)
|
||||
const editingAreaId = ref<number | null>(null)
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
geojson: ''
|
||||
})
|
||||
const jsonError = ref('')
|
||||
const actionError = ref('')
|
||||
const modalError = ref('')
|
||||
const deletingAreaId = ref<number | null>(null)
|
||||
const areaPendingDelete = ref<any | null>(null)
|
||||
|
||||
let map: any = null
|
||||
let mainMap: any = null
|
||||
let polygon: any = null
|
||||
const mapPoints = ref<[number, number][]>([])
|
||||
|
||||
const modalTitle = computed(() => editingAreaId.value ? 'Modifica Area' : 'Nuova Area')
|
||||
const submitLabel = computed(() => editingAreaId.value ? 'Salva Modifiche' : 'Crea Area')
|
||||
const isEditing = computed(() => editingAreaId.value !== null)
|
||||
const areas = computed(() => areasRes.value?.areas || [])
|
||||
const areaDeleteMessage = computed(() =>
|
||||
areaPendingDelete.value ? `Vuoi eliminare area "${areaPendingDelete.value.name}"? Operazione irreversibile.` : ''
|
||||
)
|
||||
|
||||
const initMainMap = async () => {
|
||||
const mapElement = document.getElementById('mainMap')
|
||||
if (!mapElement) return
|
||||
|
||||
const L = (await import('leaflet')).default
|
||||
|
||||
if (mainMap) {
|
||||
mainMap.remove()
|
||||
mainMap = null
|
||||
}
|
||||
|
||||
const bariBounds = L.latLngBounds(
|
||||
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||
)
|
||||
|
||||
mainMap = L.map('mainMap', {
|
||||
maxBounds: bariBounds,
|
||||
maxBoundsViscosity: 1,
|
||||
minZoom: 11
|
||||
}).setView(BARI_CENTER, 12)
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap'
|
||||
}).addTo(mainMap)
|
||||
|
||||
const featureGroup = L.featureGroup().addTo(mainMap)
|
||||
let hasLayers = false
|
||||
|
||||
areas.value.forEach((area: any) => {
|
||||
if (area.boundary_geojson) {
|
||||
try {
|
||||
const layer = L.geoJSON(JSON.parse(area.boundary_geojson), {
|
||||
style: {
|
||||
color: '#0d6efd',
|
||||
weight: 2,
|
||||
fillOpacity: 0.2
|
||||
}
|
||||
}).bindPopup(`<strong>${area.name}</strong><br/>${area.description || ''}`)
|
||||
|
||||
layer.addTo(featureGroup)
|
||||
hasLayers = true
|
||||
} catch (e) {
|
||||
console.error('Invalid geojson for area', area.id, e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (hasLayers) {
|
||||
mainMap.fitBounds(featureGroup.getBounds())
|
||||
} else {
|
||||
mainMap.fitBounds(bariBounds)
|
||||
}
|
||||
}
|
||||
|
||||
watch([areas, pending], () => {
|
||||
if (!pending.value) {
|
||||
if (areas.value.length > 0) {
|
||||
nextTick(() => {
|
||||
initMainMap()
|
||||
})
|
||||
} else if (mainMap) {
|
||||
mainMap.remove()
|
||||
mainMap = null
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const renderExistingAreasInModal = (L: any, currentMap: any) => {
|
||||
areas.value.forEach((area: any) => {
|
||||
if (area.id === editingAreaId.value) return
|
||||
|
||||
if (area.boundary_geojson) {
|
||||
try {
|
||||
L.geoJSON(JSON.parse(area.boundary_geojson), {
|
||||
style: {
|
||||
color: '#6c757d',
|
||||
weight: 2,
|
||||
dashArray: '5, 5',
|
||||
fillOpacity: 0.1
|
||||
},
|
||||
interactive: false
|
||||
}).addTo(currentMap)
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openCreateModal = () => {
|
||||
actionError.value = ''
|
||||
modalError.value = ''
|
||||
editingAreaId.value = null
|
||||
formData.name = ''
|
||||
formData.description = ''
|
||||
formData.geojson = ''
|
||||
jsonError.value = ''
|
||||
mapPoints.value = []
|
||||
isCreating.value = true
|
||||
|
||||
nextTick(async () => {
|
||||
const L = (await import('leaflet')).default
|
||||
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
|
||||
const bariBounds = L.latLngBounds(
|
||||
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||
)
|
||||
|
||||
map = L.map('areaMap', {
|
||||
maxBounds: bariBounds,
|
||||
maxBoundsViscosity: 1,
|
||||
minZoom: 12
|
||||
}).setView(BARI_CENTER, 12)
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap'
|
||||
}).addTo(map)
|
||||
|
||||
polygon = L.polygon([], { color: 'red' }).addTo(map)
|
||||
map.fitBounds(bariBounds)
|
||||
|
||||
renderExistingAreasInModal(L, map)
|
||||
|
||||
map.on('click', (e: any) => {
|
||||
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
|
||||
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
|
||||
return
|
||||
}
|
||||
|
||||
jsonError.value = ''
|
||||
modalError.value = ''
|
||||
mapPoints.value.push([e.latlng.lat, e.latlng.lng])
|
||||
polygon.setLatLngs(mapPoints.value)
|
||||
updateGeojson()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const loadPolygonFromGeoJson = (boundaryGeojson: any) => {
|
||||
const coordinates = boundaryGeojson?.coordinates?.[0]
|
||||
if (!Array.isArray(coordinates)) {
|
||||
mapPoints.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = coordinates
|
||||
.slice(0, -1)
|
||||
.map(([longitude, latitude]: [number, number]) => [latitude, longitude] as [number, number])
|
||||
|
||||
mapPoints.value = normalized
|
||||
if (polygon) {
|
||||
polygon.setLatLngs(mapPoints.value)
|
||||
}
|
||||
}
|
||||
|
||||
const openEditModal = (area: any) => {
|
||||
actionError.value = ''
|
||||
modalError.value = ''
|
||||
editingAreaId.value = area.id
|
||||
formData.name = area.name || ''
|
||||
formData.description = area.description || ''
|
||||
formData.geojson = area.boundary_geojson || ''
|
||||
jsonError.value = ''
|
||||
mapPoints.value = []
|
||||
isCreating.value = true
|
||||
|
||||
nextTick(async () => {
|
||||
const L = (await import('leaflet')).default
|
||||
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
|
||||
const bariBounds = L.latLngBounds(
|
||||
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||
)
|
||||
|
||||
map = L.map('areaMap', {
|
||||
maxBounds: bariBounds,
|
||||
maxBoundsViscosity: 1,
|
||||
minZoom: 12
|
||||
}).setView(BARI_CENTER, 12)
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap'
|
||||
}).addTo(map)
|
||||
console.log(JSON.parse(area.boundary_geojson))
|
||||
L.geoJSON(JSON.parse(area.boundary_geojson), {
|
||||
style: {
|
||||
color: '#0d6efd',
|
||||
weight: 2,
|
||||
fillOpacity: 0.2
|
||||
}
|
||||
}).addTo(map)
|
||||
|
||||
polygon = L.polygon([], { color: 'red' }).addTo(map)
|
||||
map.fitBounds(bariBounds)
|
||||
|
||||
renderExistingAreasInModal(L, map)
|
||||
|
||||
loadPolygonFromGeoJson(area.boundary_geojson)
|
||||
|
||||
map.on('click', (e: any) => {
|
||||
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
|
||||
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
|
||||
return
|
||||
}
|
||||
|
||||
jsonError.value = ''
|
||||
modalError.value = ''
|
||||
mapPoints.value.push([e.latlng.lat, e.latlng.lng])
|
||||
polygon.setLatLngs(mapPoints.value)
|
||||
updateGeojson()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const clearMap = () => {
|
||||
mapPoints.value = []
|
||||
if (polygon) {
|
||||
polygon.setLatLngs([])
|
||||
}
|
||||
formData.geojson = ''
|
||||
modalError.value = ''
|
||||
}
|
||||
|
||||
const updateGeojson = () => {
|
||||
if (mapPoints.value.length < 3) {
|
||||
formData.geojson = ''
|
||||
return
|
||||
}
|
||||
const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number])
|
||||
if (coords[0]) {
|
||||
coords.push([...coords[0]])
|
||||
}
|
||||
|
||||
formData.geojson = JSON.stringify({
|
||||
type: "Polygon",
|
||||
coordinates: [coords]
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
isCreating.value = false
|
||||
editingAreaId.value = null
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (map) map.remove()
|
||||
if (mainMap) mainMap.remove()
|
||||
})
|
||||
|
||||
const handleCreate = async () => {
|
||||
jsonError.value = ''
|
||||
modalError.value = ''
|
||||
let parsedGeojson = null
|
||||
|
||||
try {
|
||||
parsedGeojson = JSON.parse(formData.geojson)
|
||||
if (!parsedGeojson.type || !parsedGeojson.coordinates) {
|
||||
throw new Error('GeoJSON non valido. Richiesto "type" e "coordinates".')
|
||||
}
|
||||
|
||||
const coordinates = parsedGeojson.coordinates?.[0] || []
|
||||
const outsideBari = coordinates.some(([longitude, latitude]: [number, number]) => !isWithinBariBounds(latitude, longitude))
|
||||
if (outsideBari) {
|
||||
throw new Error('Area non valida. Tutti i punti devono essere nel Comune di Bari.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
jsonError.value = e.message || 'Formato JSON non valido.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
boundary_geojson: parsedGeojson
|
||||
}
|
||||
|
||||
const res = editingAreaId.value
|
||||
? await updateArea(editingAreaId.value, payload)
|
||||
: await createArea(payload)
|
||||
|
||||
if (res.ok) {
|
||||
await refresh()
|
||||
closeCreateModal()
|
||||
actionError.value = ''
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save area', err)
|
||||
|
||||
const backendError = err?.data?.error || err?.response?.data?.error || err?.response?._data?.error;
|
||||
|
||||
if (backendError === 'area_outside_allowed_boundary') {
|
||||
modalError.value = "Attenzione: l'area disegnata esce dai confini consentiti per il Comune di Bari.";
|
||||
} else {
|
||||
modalError.value = err?.data?.message || err?.message || "Errore durante il salvataggio dell'area.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const askDeleteArea = (area: any) => {
|
||||
areaPendingDelete.value = area
|
||||
}
|
||||
|
||||
const closeDeleteAreaModal = () => {
|
||||
if (deletingAreaId.value) return
|
||||
areaPendingDelete.value = null
|
||||
}
|
||||
|
||||
const handleDeleteArea = async () => {
|
||||
if (!areaPendingDelete.value) return
|
||||
|
||||
const area = areaPendingDelete.value
|
||||
|
||||
deletingAreaId.value = area.id
|
||||
actionError.value = ''
|
||||
|
||||
try {
|
||||
const res = await deleteArea(area.id)
|
||||
if (res.ok) {
|
||||
if (editingAreaId.value === area.id) {
|
||||
closeCreateModal()
|
||||
}
|
||||
await refresh()
|
||||
areaPendingDelete.value = null
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete area', err)
|
||||
actionError.value = err?.data?.message || 'Errore durante eliminazione area.'
|
||||
} finally {
|
||||
deletingAreaId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container my-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 font-weight-bold mb-0">Gestione Aree</h1>
|
||||
<div>
|
||||
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Torna al pannello admin</NuxtLink>
|
||||
<button class="btn btn-primary btn-sm" @click="openCreateModal">
|
||||
<BIIcon class="me-1" name="it-plus" size="xs"/>
|
||||
Nuova Area
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="areas.length === 0" class="alert alert-info">
|
||||
Nessuna area configurata.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="card mb-4 shadow-sm border-0">
|
||||
<div id="mainMap" class="rounded" style="height: 400px; width: 100%; z-index: 1;"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="actionError" class="alert alert-danger">
|
||||
{{ actionError }}
|
||||
</div>
|
||||
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table table-hover align-middle bg-white shadow-sm rounded">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nome</th>
|
||||
<th>Descrizione</th>
|
||||
<th>GeoJSON</th>
|
||||
<th class="text-end">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="area in areas" :key="area.id">
|
||||
<td>{{ area.id }}</td>
|
||||
<td><strong>{{ area.name }}</strong></td>
|
||||
<td>{{ area.description || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="area.boundary_geojson" class="badge border border-primary text-primary">Configurato</span>
|
||||
<span v-else class="badge border border-danger text-danger">Mancante</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-primary me-2" @click="openEditModal(area)">
|
||||
Modifica
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-danger ms-2"
|
||||
:disabled="deletingAreaId === area.id"
|
||||
@click="askDeleteArea(area)"
|
||||
>
|
||||
<span v-if="deletingAreaId === area.id" class="spinner-border spinner-border-sm me-1"></span>
|
||||
Elimina
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isCreating" class="modal-backdrop fade show d-block"
|
||||
style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
|
||||
<div v-if="isCreating" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ modalTitle }}</h5>
|
||||
<button type="button" class="btn-close" @click="closeCreateModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="modalError" class="alert alert-danger mb-4 d-flex align-items-center" role="alert">
|
||||
<strong>Errore:</strong> {{ modalError }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleCreate">
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nome Area</label>
|
||||
<input v-model="formData.name" class="form-control" placeholder="Es. Centro Storico" required
|
||||
type="text"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Descrizione (opzionale)</label>
|
||||
<textarea class="form-control" v-model="formData.description" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">GeoJSON Generato</label>
|
||||
<textarea
|
||||
class="form-control font-monospace text-sm"
|
||||
rows="6"
|
||||
v-model="formData.geojson"
|
||||
required
|
||||
readonly
|
||||
placeholder='{ "type": "Polygon", "coordinates": [...] }'
|
||||
></textarea>
|
||||
<div v-if="jsonError" class="form-text text-danger mt-1">{{ jsonError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-7">
|
||||
<div class="mb-2 d-flex justify-content-between align-items-center">
|
||||
<label class="form-label mb-0">Disegna Poligono sulla Mappa</label>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" @click="clearMap">
|
||||
Pulisci Mappa
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-light border small mb-2 p-2">
|
||||
Clicca sulla mappa per aggiungere vertici al poligono. Sono necessari almeno 3 punti. Sono ammessi
|
||||
solo punti nel Comune di Bari.
|
||||
</div>
|
||||
<div id="areaMap" class="rounded border" style="height: 400px; width: 100%; z-index: 1;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-end gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="closeCreateModal">Annulla</button>
|
||||
<button :disabled="!formData.name || !formData.geojson" class="btn btn-primary" type="button"
|
||||
@click="handleCreate">
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
:open="areaPendingDelete !== null"
|
||||
title="Elimina area"
|
||||
:message="areaDeleteMessage"
|
||||
confirm-label="Elimina area"
|
||||
confirm-class="btn-danger"
|
||||
:pending="deletingAreaId !== null"
|
||||
@cancel="closeDeleteAreaModal"
|
||||
@confirm="handleDeleteArea"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#areaMap, #mainMap {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
margin-block: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,477 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, Team } from '~/types/api'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
auth: { role: 'ADMIN' }
|
||||
})
|
||||
|
||||
useHead({ title: 'Categorie e Team - Admin' })
|
||||
|
||||
const { createCategory, updateCategory, deleteCategory, createTeam, updateTeam, deleteTeam, getAreas } = useAdmin()
|
||||
const { getCategories } = useReports()
|
||||
const { getTeams } = useOperator()
|
||||
|
||||
const {
|
||||
data: categoriesRes,
|
||||
refresh: refreshCategories
|
||||
} = await useAsyncData('report-categories-admin-view', () => getCategories())
|
||||
const { data: teamsRes, refresh: refreshTeams } = await useAsyncData('admin-teams', () => getTeams())
|
||||
const { data: areasRes } = await useAsyncData('admin-team-areas', () => getAreas())
|
||||
|
||||
const categoryForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const editingCategoryId = ref<number | null>(null)
|
||||
|
||||
const teamForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
team_category_id: null as number | null,
|
||||
area_id: null as number | null,
|
||||
})
|
||||
|
||||
const editingTeamId = ref<number | null>(null)
|
||||
|
||||
const isSavingCategory = ref(false)
|
||||
const isSavingTeam = ref(false)
|
||||
const categoryError = 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 teams = computed(() => teamsRes.value?.teams || [])
|
||||
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 () => {
|
||||
categoryError.value = ''
|
||||
isSavingCategory.value = true
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: categoryForm.name.trim(),
|
||||
description: categoryForm.description.trim() || undefined,
|
||||
}
|
||||
if (editingCategoryId.value) {
|
||||
await updateCategory(editingCategoryId.value, payload)
|
||||
} else {
|
||||
await createCategory(payload)
|
||||
}
|
||||
resetCategoryForm()
|
||||
await refreshCategories()
|
||||
} catch (error: any) {
|
||||
categoryError.value = error?.data?.message || 'Creazione categoria non riuscita.'
|
||||
} finally {
|
||||
isSavingCategory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitTeam = async () => {
|
||||
if (!teamForm.team_category_id) return
|
||||
|
||||
teamError.value = ''
|
||||
isSavingTeam.value = true
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: teamForm.name.trim(),
|
||||
description: teamForm.description.trim() || undefined,
|
||||
team_category_id: teamForm.team_category_id,
|
||||
area_id: teamForm.area_id || undefined,
|
||||
}
|
||||
if (editingTeamId.value) {
|
||||
await updateTeam(editingTeamId.value, payload)
|
||||
} else {
|
||||
await createTeam(payload)
|
||||
}
|
||||
resetTeamForm()
|
||||
await refreshTeams()
|
||||
} catch (error: any) {
|
||||
teamError.value = error?.data?.message || 'Creazione team non riuscita.'
|
||||
} finally {
|
||||
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>
|
||||
|
||||
<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">Categorie e Team</h1>
|
||||
<p class="text-muted mb-0">Gestione dati di riferimento per segnalazioni e interventi.</p>
|
||||
</div>
|
||||
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm">Torna al pannello admin</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card card-bg border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h2 class="h5 mb-0">Categorie</h2>
|
||||
<span class="badge bg-light text-dark border">{{ categories.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="categoryError" class="alert alert-danger py-2" role="alert">
|
||||
{{ categoryError }}
|
||||
</div>
|
||||
|
||||
<form class="mb-4" @submit.prevent="submitCategory">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nome categoria</label>
|
||||
<input v-model="categoryForm.name" class="form-control" required type="text"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Descrizione</label>
|
||||
<textarea v-model="categoryForm.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<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>
|
||||
{{ editingCategoryId ? 'Salva categoria' : 'Crea categoria' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editingCategoryId"
|
||||
class="btn btn-outline-secondary ms-2"
|
||||
type="button"
|
||||
@click="resetCategoryForm"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive report-table-wrapper">
|
||||
<table class="table table-hover report-table mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nome</th>
|
||||
<th>Stato</th>
|
||||
<th class="text-end">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="category in categories" :key="category.id">
|
||||
<td class="report-table-id">#{{ category.id }}</td>
|
||||
<td>
|
||||
<strong>{{ category.name }}</strong>
|
||||
<div v-if="category.description" class="small text-muted mt-1">{{ category.description }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="category.active === false ? 'bg-secondary' : 'bg-success'">
|
||||
{{ category.active === false ? 'Inattiva' : 'Attiva' }}
|
||||
</span>
|
||||
</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 v-if="categories.length === 0">
|
||||
<td class="text-muted text-center py-4" colspan="4">Nessuna categoria configurata.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card card-bg border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h2 class="h5 mb-0">Team di intervento</h2>
|
||||
<span class="badge bg-light text-dark border">{{ teams.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="teamError" class="alert alert-danger py-2" role="alert">
|
||||
{{ teamError }}
|
||||
</div>
|
||||
|
||||
<form class="row g-3 mb-4" @submit.prevent="submitTeam">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Nome team</label>
|
||||
<input v-model="teamForm.name" class="form-control" required type="text"/>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Categoria coperta</label>
|
||||
<select v-model.number="teamForm.team_category_id" class="form-select" required>
|
||||
<option :value="null">Seleziona categoria</option>
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Area</label>
|
||||
<select v-model.number="teamForm.area_id" class="form-select">
|
||||
<option :value="null">Tutte le aree</option>
|
||||
<option v-for="area in areas" :key="area.id" :value="area.id">
|
||||
{{ area.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Descrizione</label>
|
||||
<input v-model="teamForm.description" class="form-control" type="text"/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<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>
|
||||
{{ editingTeamId ? 'Salva team' : 'Crea team' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editingTeamId"
|
||||
class="btn btn-outline-secondary ms-2"
|
||||
type="button"
|
||||
@click="resetTeamForm"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive report-table-wrapper">
|
||||
<table class="table table-hover report-table mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Team</th>
|
||||
<th>Categoria</th>
|
||||
<th>Area</th>
|
||||
<th>Stato</th>
|
||||
<th class="text-end">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="team in teams" :key="team.id">
|
||||
<td class="report-table-id">#{{ team.id }}</td>
|
||||
<td>
|
||||
<strong>{{ team.name }}</strong>
|
||||
<div v-if="team.description" class="small text-muted mt-1">{{ team.description }}</div>
|
||||
</td>
|
||||
<td>{{ team.category_name || '-' }}</td>
|
||||
<td>{{ team.area_name || 'Tutte' }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="team.active === false ? 'bg-secondary' : 'bg-success'">
|
||||
{{ team.active === false ? 'Inattivo' : 'Attivo' }}
|
||||
</span>
|
||||
</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 v-if="teams.length === 0">
|
||||
<td class="text-muted text-center py-4" colspan="6">Nessun team configurato.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</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>
|
||||
@@ -1,298 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
auth: { role: 'ADMIN' }
|
||||
})
|
||||
|
||||
useHead({ title: 'Moderazione Abusi - Admin' })
|
||||
|
||||
const { getModerationFlags, updateModerationFlag } = useAdmin()
|
||||
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
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(
|
||||
'admin-moderation-flags',
|
||||
() => getModerationFlags({ page: page.value, per_page: perPage.value, status: statusFilter.value || undefined }),
|
||||
{ watch: [page, statusFilter] }
|
||||
)
|
||||
|
||||
const openResolveModal = (id: number, rejectReport: boolean) => {
|
||||
resolveModal.open = true
|
||||
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 {
|
||||
await updateModerationFlag(resolveModal.flagId, {
|
||||
status: 'RESOLVED',
|
||||
reject_report: resolveModal.rejectReport,
|
||||
resolution_note: resolveModal.note || undefined
|
||||
})
|
||||
|
||||
resolveModal.pending = false
|
||||
await refresh()
|
||||
closeResolveModal()
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve flag', err)
|
||||
actionError.value = 'Errore durante la risoluzione della segnalazione.'
|
||||
} finally {
|
||||
resolveModal.pending = false
|
||||
}
|
||||
}
|
||||
|
||||
const openDismissModal = (id: number) => {
|
||||
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 {
|
||||
await updateModerationFlag(dismissModal.flagId, { status: 'DISMISSED' })
|
||||
|
||||
dismissModal.pending = false
|
||||
await refresh()
|
||||
closeDismissModal()
|
||||
} catch (err) {
|
||||
console.error('Failed to dismiss flag', err)
|
||||
actionError.value = 'Errore durante l\'aggiornamento.'
|
||||
} finally {
|
||||
dismissModal.pending = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('it-IT', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="container my-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 font-weight-bold mb-0">Moderazione Abusi</h1>
|
||||
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm">Torna al pannello admin</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Filtra per Stato</label>
|
||||
<select class="form-select" v-model="statusFilter">
|
||||
<option value="">Tutti</option>
|
||||
<option value="OPEN">Aperti</option>
|
||||
<option value="RESOLVED">Risolti</option>
|
||||
<option value="DISMISSED">Ignorati</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="actionError" class="alert alert-danger mb-4">
|
||||
{{ actionError }}
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="text-center py-5">
|
||||
<div class="spinner-border text-danger" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!flagsRes || !flagsRes.flags || flagsRes.flags.length === 0" class="alert alert-success">
|
||||
Nessun elemento da moderare al momento.
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div
|
||||
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>
|
||||
<strong>Flag #{{ flag.id }}</strong> -
|
||||
<span class="badge" :class="{
|
||||
'bg-warning': flag.status === 'OPEN',
|
||||
'bg-success': flag.status === 'RESOLVED',
|
||||
'bg-secondary': flag.status === 'DISMISSED'
|
||||
}">{{ formatFlagStatus(flag.status) }}</span>
|
||||
</div>
|
||||
<small class="text-muted">{{ formatDate(flag.created_at) }}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h5 class="card-title">Motivo: {{ flag.reason }}</h5>
|
||||
<p class="card-text text-muted mb-2">
|
||||
{{ flag.details || 'Nessun dettaglio aggiuntivo fornito.' }}
|
||||
</p>
|
||||
|
||||
<div class="mt-info">
|
||||
<span class="badge bg-info me-2">{{ contentTypeLabel(flag.content_type) }}</span>
|
||||
<span v-if="flag.report_id">
|
||||
Rif:
|
||||
<NuxtLink :to="`/operator/reports/${flag.report_id}`" class="text-decoration-none">
|
||||
Segnalazione #{{ flag.report_id }}
|
||||
</NuxtLink>
|
||||
</span>
|
||||
<span v-if="flag.message_id" class="ms-2">
|
||||
| Messaggio #{{ flag.message_id }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-info small text-muted">
|
||||
Segnalato da: {{ flag.created_by_email }} (ID: {{ flag.created_by_user_id }})
|
||||
</div>
|
||||
|
||||
<div v-if="flag.status !== 'OPEN'" class="mt-2 small text-muted border-top pt-2">
|
||||
<strong>Risolto/Ignorato da:</strong> {{ flag.resolved_by_email }} in data {{ flag.resolved_at ? formatDate(flag.resolved_at) : 'N/D' }}
|
||||
<div v-if="flag.resolution_note"><strong>Nota:</strong> {{ flag.resolution_note }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<button class="btn btn-outline-danger btn-sm" @click="openResolveModal(flag.id, canRejectReport(flag))">
|
||||
<BIIcon name="it-ban" size="xs" /> {{ canRejectReport(flag) ? 'Risolvi & Rigetta Segn.' : 'Risolvi flag' }}
|
||||
</button>
|
||||
<button class="btn btn-outline-warning btn-sm" @click="openResolveModal(flag.id, false)">
|
||||
<BIIcon name="it-check" size="xs" /> {{ canRejectReport(flag) ? 'Risolvi (Solo Avviso)' : 'Risolvi senza rigetto' }}
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="openDismissModal(flag.id)">
|
||||
<BIIcon name="it-close" size="xs" /> Ignora Flag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
v-if="(flagsRes.total || flagsRes.flags.length) > perPage"
|
||||
:current-page="page"
|
||||
:per-page="perPage"
|
||||
:total-items="flagsRes.total || (page * perPage + 1)"
|
||||
@page-change="page = $event"
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
@@ -1,258 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Report, ReportStatus } from '~/types/api'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
auth: { role: 'ADMIN' }
|
||||
})
|
||||
|
||||
useHead({ title: 'Overview Segnalazioni - Admin' })
|
||||
|
||||
const { getReports, getCategories } = useReports()
|
||||
const { getAreas } = useAdmin()
|
||||
|
||||
const search = ref('')
|
||||
const selectedStatus = ref<'ALL' | ReportStatus>('ALL')
|
||||
const selectedCategoryId = ref<number | 'ALL'>('ALL')
|
||||
const selectedAreaId = ref<number | 'ALL'>('ALL')
|
||||
|
||||
const { data: reportsRes, pending } = await useAsyncData('admin-report-overview', () => getReports())
|
||||
const { data: categoriesRes } = await useAsyncData('admin-report-overview-categories', () => getCategories())
|
||||
const { data: areasRes } = await useAsyncData('admin-report-overview-areas', () => getAreas())
|
||||
|
||||
const reports = computed<Report[]>(() => reportsRes.value?.reports || [])
|
||||
const categories = computed(() => categoriesRes.value?.categories || [])
|
||||
const areas = computed(() => areasRes.value?.areas || [])
|
||||
|
||||
const statusMeta: Record<ReportStatus, { label: string; tone: string }> = {
|
||||
OPEN: { label: 'Aperta', tone: 'danger' },
|
||||
UNDER_REVIEW: { label: 'In revisione', tone: 'warning' },
|
||||
ASSIGNED: { label: 'Assegnata', tone: 'info' },
|
||||
IN_PROGRESS: { label: 'In corso', tone: 'primary' },
|
||||
RESOLVED: { label: 'Risolta', tone: 'success' },
|
||||
REJECTED: { label: 'Rigettata', tone: 'secondary' },
|
||||
CLOSED: { label: 'Chiusa', tone: 'dark' }
|
||||
}
|
||||
|
||||
const filteredReports = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
|
||||
return reports.value.filter((report) => {
|
||||
const matchesStatus = selectedStatus.value === 'ALL' || report.status === selectedStatus.value
|
||||
const matchesCategory = selectedCategoryId.value === 'ALL' || report.category_id === selectedCategoryId.value
|
||||
const matchesArea = selectedAreaId.value === 'ALL' || (report.area_id ?? null) === selectedAreaId.value
|
||||
const matchesSearch = !term || [
|
||||
report.id,
|
||||
report.address,
|
||||
report.description,
|
||||
report.category_name,
|
||||
report.area_name,
|
||||
report.assigned_operator_id ? `#${report.assigned_operator_id}` : ''
|
||||
].some((value) => String(value || '').toLowerCase().includes(term))
|
||||
|
||||
return matchesStatus && matchesCategory && matchesArea && matchesSearch
|
||||
})
|
||||
})
|
||||
|
||||
const totalReports = computed(() => reports.value.length)
|
||||
const openReports = computed(() => reports.value.filter((report) => report.status === 'OPEN').length)
|
||||
const activeReports = computed(() => reports.value.filter((report) => ['UNDER_REVIEW', 'ASSIGNED', 'IN_PROGRESS'].includes(report.status)).length)
|
||||
const closedReports = computed(() => reports.value.filter((report) => ['RESOLVED', 'CLOSED'].includes(report.status)).length)
|
||||
|
||||
const reportsByStatus = computed(() =>
|
||||
Object.entries(statusMeta)
|
||||
.map(([status, meta]) => ({
|
||||
status: status as ReportStatus,
|
||||
label: meta.label,
|
||||
tone: meta.tone,
|
||||
total: reports.value.filter((report) => report.status === status).length
|
||||
}))
|
||||
.filter((item) => item.total > 0)
|
||||
)
|
||||
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(12)
|
||||
|
||||
watch([search, selectedStatus, selectedCategoryId, selectedAreaId], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.ceil(filteredReports.value.length / itemsPerPage.value))
|
||||
|
||||
const paginatedReports = computed(() => {
|
||||
const sorted = [...filteredReports.value].sort(
|
||||
(a, b) => new Date(b.created_at || '').getTime() - new Date(a.created_at || '').getTime()
|
||||
)
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value
|
||||
const end = start + itemsPerPage.value
|
||||
return sorted.slice(start, end)
|
||||
})
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const truncate = (value?: string, max = 120) => {
|
||||
if (!value) return '-'
|
||||
return value.length > max ? `${value.slice(0, max).trim()}...` : value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container my-5">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 font-weight-bold mb-1">Overview Segnalazioni</h1>
|
||||
<p class="text-muted mb-0">Vista completa in sola lettura per monitorare carico, stato e distribuzione.</p>
|
||||
</div>
|
||||
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm">Torna al pannello admin</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-xl-3" v-for="card in [
|
||||
{ label: 'Totali', value: totalReports, tone: 'primary' },
|
||||
{ label: 'Aperte', value: openReports, tone: 'danger' },
|
||||
{ label: 'In lavorazione', value: activeReports, tone: 'warning' },
|
||||
{ label: 'Chiuse/Risolte', value: closedReports, tone: 'success' }
|
||||
]" :key="card.label">
|
||||
<div class="card card-bg dashboard-stat h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="stat-value">{{ card.value }}</div>
|
||||
<div class="small text-muted">{{ card.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-lg-4">
|
||||
<label class="form-label">Cerca</label>
|
||||
<input v-model="search" type="text" class="form-control" placeholder="ID, indirizzo, descrizione, area..." />
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-lg-3">
|
||||
<label class="form-label">Stato</label>
|
||||
<select v-model="selectedStatus" class="form-select">
|
||||
<option value="ALL">Tutti</option>
|
||||
<option v-for="item in reportsByStatus" :key="item.status" :value="item.status">{{ item.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-lg-2">
|
||||
<label class="form-label">Categoria</label>
|
||||
<select v-model="selectedCategoryId" class="form-select">
|
||||
<option value="ALL">Tutte</option>
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">{{ category.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-lg-3">
|
||||
<label class="form-label">Area</label>
|
||||
<select v-model="selectedAreaId" class="form-select">
|
||||
<option value="ALL">Tutte</option>
|
||||
<option v-for="area in areas" :key="area.id" :value="area.id">{{ area.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||
<h2 class="h5 mb-0">Distribuzione stati</h2>
|
||||
<span class="text-muted small">{{ filteredReports.length }} elementi trovati</span>
|
||||
</div>
|
||||
<div class="table-responsive report-table-wrapper">
|
||||
<table class="table table-hover report-table mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Stato</th>
|
||||
<th class="text-end">Totale</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportsByStatus" :key="item.status">
|
||||
<td>
|
||||
<span class="badge" :class="`bg-${item.tone}`">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">{{ item.total }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredReports.length === 0" class="alert alert-info">
|
||||
Nessuna segnalazione trovata con filtri correnti.
|
||||
</div>
|
||||
|
||||
<div v-else class="card card-bg border-0 shadow-sm mb-5">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive report-table-wrapper">
|
||||
<table class="table table-hover report-table mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Categoria</th>
|
||||
<th>Indirizzo</th>
|
||||
<th>Area</th>
|
||||
<th>Stato</th>
|
||||
<th>Operatore</th>
|
||||
<th>Aggiornata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="report in paginatedReports" :key="report.id">
|
||||
<td class="report-table-id">#{{ report.id }}</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ report.category_name || '-' }}</div>
|
||||
<small class="text-muted">{{ truncate(report.description, 72) }}</small>
|
||||
</td>
|
||||
<td>{{ report.address || '-' }}</td>
|
||||
<td>{{ report.area_name || 'Non assegnata' }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="`bg-${statusMeta[report.status].tone}`">
|
||||
{{ statusMeta[report.status].label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ report.assigned_operator_id ? `#${report.assigned_operator_id}` : '-' }}</td>
|
||||
<td class="text-muted text-nowrap">{{ formatDate(report.updated_at || report.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent d-flex flex-wrap justify-content-between align-items-center py-3" v-if="totalPages > 1">
|
||||
<span class="text-muted small mb-2 mb-sm-0">Pagina {{ currentPage }} di {{ totalPages }}</span>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:total-items="filteredReports.length"
|
||||
@page-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,227 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
auth: { role: 'ADMIN' }
|
||||
})
|
||||
|
||||
useHead({ title: 'Gestione Utenti - Admin' })
|
||||
|
||||
const { getUsers, updateUserBan, updateUserRoles } = useAdmin()
|
||||
|
||||
const { data: usersRes, pending, refresh } = useAsyncData('admin-users', () => getUsers())
|
||||
|
||||
const search = ref('')
|
||||
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(() => {
|
||||
if (!usersRes.value?.users) return []
|
||||
if (!search.value) return usersRes.value.users
|
||||
const s = search.value.toLowerCase()
|
||||
return usersRes.value.users.filter(u =>
|
||||
u.first_name.toLowerCase().includes(s) ||
|
||||
u.last_name.toLowerCase().includes(s) ||
|
||||
u.email.toLowerCase().includes(s) ||
|
||||
u.fiscal_code?.toLowerCase().includes(s)
|
||||
)
|
||||
})
|
||||
|
||||
const askToggleBan = (user: any) => {
|
||||
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 {
|
||||
const res = await updateUserBan(user.id, { banned: !user.banned })
|
||||
if (res.ok) {
|
||||
await refresh()
|
||||
pendingBanUser.value = null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update user ban status', err)
|
||||
banError.value = 'Errore durante l\'aggiornamento dello stato di ban.'
|
||||
} finally {
|
||||
isUpdatingBan.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editingUserId = ref<number | null>(null)
|
||||
const selectedRoles = ref<string[]>([])
|
||||
const availableRoles = ['CITIZEN', 'OPERATOR', 'ADMIN']
|
||||
|
||||
const openRoleModal = (user: any) => {
|
||||
editingUserId.value = user.id
|
||||
selectedRoles.value = parseRoles(user.roles)
|
||||
}
|
||||
|
||||
const closeRoleModal = () => {
|
||||
editingUserId.value = null
|
||||
selectedRoles.value = []
|
||||
}
|
||||
|
||||
const saveRoles = async () => {
|
||||
if (!editingUserId.value) return
|
||||
|
||||
roleError.value = ''
|
||||
try {
|
||||
const res = await updateUserRoles(editingUserId.value, { roles: selectedRoles.value })
|
||||
if (res.ok) {
|
||||
await refresh()
|
||||
closeRoleModal()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update roles', err)
|
||||
roleError.value = 'Errore durante l\'aggiornamento dei ruoli.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container my-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 font-weight-bold mb-0">Gestione Utenti</h1>
|
||||
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm">Torna al pannello admin</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" placeholder="Cerca per nome, email o CF..." v-model="search">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!usersRes?.users || usersRes.users.length === 0" class="alert alert-info">
|
||||
Nessun utente trovato.
|
||||
</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">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nome</th>
|
||||
<th>Email / CF</th>
|
||||
<th>Ruoli</th>
|
||||
<th>Punti</th>
|
||||
<th>Stato</th>
|
||||
<th class="text-end">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in filteredUsers" :key="user.id">
|
||||
<td>{{ user.id }}</td>
|
||||
<td>
|
||||
<strong>{{ user.first_name }} {{ user.last_name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ user.email }}</div>
|
||||
<small class="text-muted">{{ user.fiscal_code }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span v-for="r in parseRoles(user.roles)" :key="r" class="badge bg-primary me-1">
|
||||
{{ r }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user.points || 0 }}</td>
|
||||
<td>
|
||||
<span v-if="user.banned" class="badge bg-danger">Bannato</span>
|
||||
<span v-else class="badge bg-success">Attivo</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary me-2"
|
||||
@click="openRoleModal(user)"
|
||||
>
|
||||
Ruoli
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="user.banned ? 'btn-outline-success' : 'btn-outline-danger'"
|
||||
@click="askToggleBan(user)"
|
||||
>
|
||||
{{ user.banned ? 'Sblocca' : 'Banna' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="editingUserId !== null" class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Modifica Ruoli Utente</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @click="closeRoleModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-check mb-2" v-for="role in availableRoles" :key="role">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:value="role"
|
||||
:id="'role-'+role"
|
||||
:checked="selectedRoles.includes(role)"
|
||||
v-model="selectedRoles"
|
||||
>
|
||||
<label class="form-check-label" :for="'role-'+role">
|
||||
{{ role }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeRoleModal">Annulla</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveRoles">Salva</button>
|
||||
</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>
|
||||
</template>
|
||||
@@ -1,153 +0,0 @@
|
||||
<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>
|
||||
@@ -1,192 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'guest'
|
||||
})
|
||||
|
||||
useHead({ title: 'Accedi - Visione' })
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const { requestOtp, verifyOtp, handleLoginSuccess, startCieLogin } = useAuth()
|
||||
|
||||
const otpStep = ref<1 | 2>(1)
|
||||
const isLoading = ref(false)
|
||||
const isCieLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const email = ref('')
|
||||
const otpCode = ref('')
|
||||
|
||||
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 () => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await requestOtp({ email: email.value })
|
||||
otpStep.value = 2
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err.data?.message || 'Errore durante la richiesta OTP. Riprova.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOtp = async () => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const res = await verifyOtp({ email: email.value, code: otpCode.value })
|
||||
await handleLoginSuccess(res.token)
|
||||
} catch (err: any) {
|
||||
errorMessage.value = 'Codice OTP non valido o scaduto.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCieLogin = async () => {
|
||||
isCieLoading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const res = await startCieLogin(cieCallbackUrl)
|
||||
await navigateTo(res.authorization_url, {
|
||||
external: true,
|
||||
replace: true,
|
||||
})
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err.data?.message || 'Avvio autenticazione CIE non riuscito. Riprova.'
|
||||
isCieLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleOTPInput = async (value: string) => {
|
||||
console.log(value)
|
||||
}
|
||||
</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-5">
|
||||
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<BIIcon name="it-user" size="xl" color="primary" class="mb-2" />
|
||||
<h1 class="h3 font-weight-bold">Accedi a Visione</h1>
|
||||
<p class="text-muted">Il portale delle segnalazioni urbane</p>
|
||||
</div>
|
||||
|
||||
<div class="card card-bg shadow-sm">
|
||||
<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 entraconcie"
|
||||
:disabled="isCieLoading || isLoading"
|
||||
@click="handleCieLogin"
|
||||
>
|
||||
<span v-if="isCieLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
​
|
||||
</button>
|
||||
|
||||
<div class="d-flex align-items-center my-4">
|
||||
<hr class="flex-grow-1">
|
||||
<span class="mx-3 text-muted small text-uppercase">Oppure</span>
|
||||
<hr class="flex-grow-1">
|
||||
</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">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<form v-if="otpStep === 1" @submit.prevent="handleRequestOtp">
|
||||
<div class="form-group mb-4">
|
||||
<input type="email" class="form-control" id="email" v-model="email" required>
|
||||
<label for="email" :class="{ 'active': email.length > 0 }">Indirizzo Email</label>
|
||||
</div>
|
||||
<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>
|
||||
Entra
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form v-else @submit.prevent="handleVerifyOtp">
|
||||
<div v-show="!errorMessage" class="alert alert-success mb-4 small">
|
||||
Abbiamo inviato un codice OTP a <strong>{{ email }}</strong>.
|
||||
</div>
|
||||
<div class="form-group mb-4">
|
||||
<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>
|
||||
</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>
|
||||
Verifica e Accedi
|
||||
</button>
|
||||
<div class="text-center">
|
||||
<a href="#" class="small text-decoration-none" @click.prevent="otpStep = 1">Modifica indirizzo email</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<p class="small text-muted">Non hai un account? <NuxtLink to="/auth/register" class="font-weight-bold">Registrati ora</NuxtLink></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-tracking-widest {
|
||||
letter-spacing: 0.5em;
|
||||
}
|
||||
.pe-5 {
|
||||
padding-right: 3rem !important;
|
||||
}
|
||||
|
||||
button.entraconcie {
|
||||
background-image: url('/entraconcie.png');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import CodiceFiscale from 'codice-fiscale-js'
|
||||
import { ref } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'guest'
|
||||
})
|
||||
|
||||
useHead({ title: 'Registrazione - Visione' })
|
||||
|
||||
const { register } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref(false)
|
||||
|
||||
const form = ref({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
fiscal_code: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
privacy: false
|
||||
})
|
||||
|
||||
const normalizeFiscalCode = (fiscalCode = '') => fiscalCode.trim().toUpperCase()
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!form.value.privacy) {
|
||||
errorMessage.value = "È necessario accettare l'informativa sulla privacy."
|
||||
return
|
||||
}
|
||||
|
||||
const fiscalCode = normalizeFiscalCode(form.value.fiscal_code)
|
||||
const expected = CodiceFiscale.surnameCode(form.value.last_name) + CodiceFiscale.nameCode(form.value.first_name)
|
||||
|
||||
if (!CodiceFiscale.check(fiscalCode)) {
|
||||
errorMessage.value = 'Il Codice Fiscale inserito non è valido.'
|
||||
return
|
||||
}
|
||||
|
||||
if (fiscalCode.slice(0, 6) !== expected) {
|
||||
errorMessage.value = 'Il Codice Fiscale non corrisponde a nome e cognome inseriti.'
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await register({
|
||||
first_name: form.value.first_name,
|
||||
last_name: form.value.last_name,
|
||||
fiscal_code: fiscalCode,
|
||||
email: form.value.email,
|
||||
phone: form.value.phone || undefined
|
||||
})
|
||||
|
||||
successMessage.value = true
|
||||
setTimeout(() => {
|
||||
router.push('/auth/login')
|
||||
}, 3000)
|
||||
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err.data?.message || 'Errore durante la registrazione. Controlla i dati inseriti.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</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-lg-8">
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<BIIcon name="it-user" size="xl" color="primary" class="mb-2" />
|
||||
<h1 class="h3 font-weight-bold">Registrazione Cittadino</h1>
|
||||
<p class="text-muted">Crea il tuo profilo per iniziare a segnalare</p>
|
||||
</div>
|
||||
|
||||
<div class="card card-bg shadow-sm">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
|
||||
<div v-if="successMessage" class="alert alert-success" role="alert">
|
||||
Registrazione completata con successo! Reindirizzamento all'accesso con OTP...
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="alert alert-danger mb-4" role="alert">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<form v-if="!successMessage" @submit.prevent="handleRegister">
|
||||
|
||||
<h2 class="h5 text-primary font-weight-bold mb-4 border-bottom pb-2">Dati Anagrafici</h2>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-12 col-md-6 mb-4 mb-md-0">
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" id="nome" v-model="form.first_name" required>
|
||||
<label for="nome" :class="{ 'active': form.first_name.length > 0 }">Nome</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" id="cognome" v-model="form.last_name" required>
|
||||
<label for="cognome" :class="{ 'active': form.last_name.length > 0 }">Cognome</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<input type="text" class="form-control text-uppercase" id="cf" v-model="form.fiscal_code" required maxlength="16">
|
||||
<label for="cf" :class="{ 'active': form.fiscal_code.length > 0 }">Codice Fiscale</label>
|
||||
<small class="form-text text-muted">Sarà utilizzato come identificativo univoco nel sistema PA.</small>
|
||||
</div>
|
||||
|
||||
<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="col-12">
|
||||
<div class="form-group position-relative">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-5">
|
||||
<input type="tel" class="form-control" id="phone" v-model="form.phone">
|
||||
<label for="phone" :class="{ 'active': form.phone?.length > 0 }">Numero di telefono (Opzionale)</label>
|
||||
<small class="form-text text-muted">Faciliterà il contatto da parte degli operatori comunali in caso di intervento.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-4">
|
||||
<input class="form-check-input" type="checkbox" id="privacyCheck" v-model="form.privacy" required>
|
||||
<label class="form-check-label small" for="privacyCheck">
|
||||
Dichiaro di aver letto l'<NuxtLink to="/privacy" target="_blank">Informativa sulla Privacy</NuxtLink> e accetto il trattamento dei dati personali.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 btn-lg" :disabled="isLoading">
|
||||
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Registra Profilo
|
||||
</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>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<p class="small text-muted">Hai già un account? <NuxtLink to="/auth/login" class="font-weight-bold">Torna al Login</NuxtLink></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const { user } = useAuth()
|
||||
|
||||
import CitizenDashboard from '~/components/dashboard/CitizenDashboard.vue'
|
||||
import OperatorDashboard from '~/components/dashboard/OperatorDashboard.vue'
|
||||
import AdminDashboard from '~/components/dashboard/AdminDashboard.vue'
|
||||
|
||||
const currentDashboard = computed(() => {
|
||||
if (isCitizen(user.value?.role)) return CitizenDashboard
|
||||
if (isOperator(user.value?.role)) return OperatorDashboard
|
||||
if (isAdmin(user.value?.role)) return AdminDashboard
|
||||
return null
|
||||
})
|
||||
|
||||
const heroTitle = computed(() => {
|
||||
if (isAdmin(user.value?.role)) return 'Pannello Admin'
|
||||
if (isOperator(user.value?.role)) return 'Gestione Operativa'
|
||||
return 'Dashboard'
|
||||
})
|
||||
|
||||
const heroSubtitle = computed(() => {
|
||||
if (isAdmin(user.value?.role)) return 'Accesso amministrativo per configurazioni, utenti e moderazione'
|
||||
if (isOperator(user.value?.role)) return 'Prendi in carico e gestisci le segnalazioni in corso'
|
||||
return 'Riepilogo rapido delle tue segnalazioni e dei punti cittadino'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="it-hero-wrapper it-primary hero-compact">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<div class="it-hero-text-wrapper p-4">
|
||||
<h1 class="text-white font-weight-bold">{{ heroTitle }}</h1>
|
||||
<p class="text-white mt-2 lead">
|
||||
{{ heroSubtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container my-5">
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h4 class="mb-2 text-muted">
|
||||
Benvenuto, <strong>{{ user?.first_name }} {{ user?.last_name }}</strong>.
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<component :is="currentDashboard" v-if="currentDashboard" />
|
||||
|
||||
<div v-else class="alert alert-warning" role="alert">
|
||||
Caricamento pannello di controllo in corso...
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,8 +3,6 @@ import BIIcon from "~/components/BIIcon.vue";
|
||||
|
||||
const { getOpenDataReports } = usePublic()
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
const stats = {
|
||||
total_reports: 14523,
|
||||
resolved_reports: 13890,
|
||||
@@ -14,17 +12,6 @@ const stats = {
|
||||
const { data: openDataResponse } = await useAsyncData('recent-fixes', () =>
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -32,15 +19,15 @@ const getStatusBadge = (status: string) => {
|
||||
<div class="it-hero-wrapper bg-light">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 mx-auto text-center">
|
||||
<div class="col-12 col-lg-8 offset-lg-2 text-center">
|
||||
<div class="it-hero-text-wrapper p-4">
|
||||
<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">
|
||||
Il portale ufficiale della città metropolitana di Bari per segnalare disservizi urbani. Scatta una foto, invia la segnalazione
|
||||
<h1 class="no_toc font-weight-bold text-dark">Miglioriamo insieme la città</h1>
|
||||
<p class="d-none d-lg-block mt-3 mb-4 text-secondary">
|
||||
Il portale ufficiale nazionale per segnalare disservizi urbani. Scatta una foto, invia la segnalazione
|
||||
e monitora l'intervento in totale trasparenza.
|
||||
</p>
|
||||
<div class="it-btn-container d-flex justify-content-center gap-3">
|
||||
<NuxtLink v-if="!user || isCitizen(user.role)" class="btn btn-primary" to="/auth/login">
|
||||
<NuxtLink class="btn btn-primary" to="/auth/login">
|
||||
Fai una segnalazione
|
||||
</NuxtLink>
|
||||
<NuxtLink class="btn btn-outline-primary" to="/open-data">
|
||||
@@ -58,29 +45,30 @@ const getStatusBadge = (status: string) => {
|
||||
<div class="row text-center">
|
||||
<div class="col-12 col-md-4 mb-4">
|
||||
<div class="mb-3">
|
||||
<BIIcon color="primary" name="it-camera" size="xl"/>
|
||||
<BIIcon name="it-camera" size="xl" color="primary" />
|
||||
</div>
|
||||
<h3 class="h5 font-weight-semibold">1. Segnala</h3>
|
||||
<p class="text-muted">Fotografa il problema stradale e condividi la tua posizione.</p>
|
||||
<p class="text-muted">Usa lo smartphone per fotografare il problema stradale e inviare la posizione
|
||||
esatta.</p>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 mb-4">
|
||||
<div class="mb-3">
|
||||
<BIIcon color="primary" name="it-tool" size="xl"/>
|
||||
<BIIcon name="it-tool" size="xl" color="primary" />
|
||||
</div>
|
||||
<h3 class="h5 font-weight-semibold">2. Intervento</h3>
|
||||
<p class="text-muted">Il Comune attiva subito le squadre operative per risolvere il problema.</p>
|
||||
<p class="text-muted">Le squadre comunali prendono in carico la tua segnalazione e avviano i lavori.</p>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 mb-4">
|
||||
<div class="mb-3">
|
||||
<BIIcon color="primary" name="it-star-outline" size="xl"/>
|
||||
<BIIcon name="it-star-outline" size="xl" color="primary" />
|
||||
</div>
|
||||
<h3 class="h5 font-weight-semibold">3. Punti e Feedback</h3>
|
||||
<p class="text-muted">Problema risolto! Guadagna Punti Cittadino e valuta l'intervento.</p>
|
||||
<p class="text-muted">Problema risolto! Ottieni Punti Cittadino e lascia un feedback sull'intervento.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-light py-5">
|
||||
<div class="bg-light py-5 border-top border-bottom">
|
||||
<div class="container my-4">
|
||||
<h2 class="text-center mb-5 font-weight-bold">I numeri del nostro Comune</h2>
|
||||
<div class="row text-center">
|
||||
@@ -108,46 +96,31 @@ const getStatusBadge = (status: string) => {
|
||||
<h2 class="font-weight-bold">Ultimi interventi risolti</h2>
|
||||
<NuxtLink to="/open-data" class="read-more">
|
||||
<span class="text">Vedi tutti</span>
|
||||
<BIIcon name="it-arrow-right"/>
|
||||
<BIIcon name="it-arrow-right" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<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="card-wrapper card-space h-100">
|
||||
<div class="card card-bg h-100 shadow-sm">
|
||||
<div class="card-body d-flex flex-column">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div class="category-top m-0">
|
||||
<span :class="getStatusBadge(report.status).class"
|
||||
class="badge rounded-pill text-white px-3 py-1">
|
||||
<BIIcon :name="getStatusBadge(report.status).icon" class="me-1" color="white" size="xs"/>
|
||||
{{ getStatusBadge(report.status).text }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-wrapper card-space">
|
||||
<div class="card card-bg card-big">
|
||||
<div class="card-body">
|
||||
<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>
|
||||
|
||||
<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 class="card-text text-muted">
|
||||
<span class="me-1"><BIIcon name="it-map-marker-circle" size="xs" /></span>
|
||||
{{ report.area_name || 'Area comunale' }}
|
||||
</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 class="it-card-footer mt-4">
|
||||
<span class="card-signature">
|
||||
Aggiornato il {{ new Date(report.updated_at).toLocaleDateString('it-IT') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,11 +131,11 @@ const getStatusBadge = (status: string) => {
|
||||
I dati sugli ultimi interventi sono in fase di elaborazione.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!user" class="container my-5 pb-5">
|
||||
|
||||
<div class="container my-5 pb-5">
|
||||
<div class="callout callout-highlight callout-primary">
|
||||
<div class="callout-title">
|
||||
<BIIcon name="it-info-circle"/>
|
||||
<BIIcon name="it-info-circle" />
|
||||
Partecipa alla cura della tua città
|
||||
</div>
|
||||
<p>
|
||||
@@ -176,20 +149,4 @@ const getStatusBadge = (status: string) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
@@ -1,223 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useMembers } from "~/composables/useMembers";
|
||||
|
||||
useHead({
|
||||
title: 'Informazioni sul Progetto - Visione',
|
||||
})
|
||||
|
||||
const { sortedMembers } = useMembers();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="it-hero-wrapper it-primary">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<div class="it-hero-text-wrapper p-4">
|
||||
<h1 class="text-white font-weight-bold">Il Progetto "Visione"</h1>
|
||||
<p class="text-white mt-3 lead font-weight-semibold">
|
||||
La piattaforma digitale della Città Metropolitana di Bari per la partecipazione attiva,
|
||||
la trasparenza e la gestione condivisa del territorio.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container my-5 py-3">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 offset-lg-2">
|
||||
<h2 class="mb-4 font-weight-bold">Un nuovo dialogo tra Cittadini ed Ente</h2>
|
||||
<p class="lead text-muted">
|
||||
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>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-light py-5">
|
||||
<div class="container">
|
||||
<h2 class="text-center mb-5 font-weight-bold">Come si articola l'ecosistema digitale</h2>
|
||||
<div class="row">
|
||||
|
||||
<div class="col-12 col-md-4 mb-4">
|
||||
<div class="card-wrapper card-space h-100">
|
||||
<div class="card card-bg h-100">
|
||||
<div class="card-body">
|
||||
<BIIcon class="mb-3" color="primary" name="it-user" size="xl"/>
|
||||
<h3 class="card-title h5 font-weight-bold">I Cittadini</h3>
|
||||
<p class="card-text text-muted">
|
||||
L'accesso è garantito attraverso l'autocompletamento anagrafico e il supporto ai sistemi di identità
|
||||
digitale
|
||||
nazionali (<strong>CIE</strong>).
|
||||
<br><br>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4 mb-4">
|
||||
<div class="card-wrapper card-space h-100">
|
||||
<div class="card card-bg h-100">
|
||||
<div class="card-body">
|
||||
<BIIcon class="mb-3" color="primary" name="it-tool" size="xl"/>
|
||||
<h3 class="card-title h5 font-weight-bold">Squadre Operative</h3>
|
||||
<p class="card-text text-muted">
|
||||
Gli operatori tecnici e logistici dell'Ente prendono in carico la segnalazione per l'immediata
|
||||
istruttoria.
|
||||
<br><br>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4 mb-4">
|
||||
<div class="card-wrapper card-space h-100">
|
||||
<div class="card card-bg h-100">
|
||||
<div class="card-body">
|
||||
<BIIcon class="mb-3" color="primary" name="it-settings" size="xl"/>
|
||||
<h3 class="card-title h5 font-weight-bold">L'Amministrazione</h3>
|
||||
<p class="card-text text-muted">
|
||||
Gli amministratori dispongono del controllo completo sulle operazioni, con privilegi di gestione dei
|
||||
profili e assegnazione delle nomine agli operatori.
|
||||
<br><br>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container my-5 py-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-12 col-lg-7 mb-4 mb-lg-0">
|
||||
<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">
|
||||
<li class="d-flex align-items-start mb-3">
|
||||
<BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-pa"/>
|
||||
<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 class="d-flex align-items-start mb-3">
|
||||
<BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-lock"/>
|
||||
<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 class="d-flex align-items-start mb-3">
|
||||
<BIIcon class="me-3 mt-1 flex-shrink-0" color="primary" name="it-mail"/>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-4 offset-lg-1">
|
||||
<div class="callout callout-highlight callout-note">
|
||||
<div class="callout-title">
|
||||
<BIIcon name="it-info-circle"/>
|
||||
Customer Satisfaction Integrata
|
||||
</div>
|
||||
<p>
|
||||
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>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mb-5 pb-5">
|
||||
<div class="card-wrapper card-space">
|
||||
<div class="card card-bg border-start border-primary border-5">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-12 col-md-6 mb-4 mb-md-0">
|
||||
<h4 class="h5 font-weight-bold text-primary mb-2">Il Team di Sviluppo</h4>
|
||||
<p class="mb-1">
|
||||
Questo portale è stato ideato e sviluppato per facilitare il percorso di transizione digitale
|
||||
e modernizzare l'infrastruttura telematica a disposizione della cittadinanza.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="d-flex flex-wrap justify-content-md-end justify-content-center gap-4">
|
||||
<div
|
||||
v-for="member in sortedMembers"
|
||||
:key="member.name"
|
||||
class="d-flex flex-column align-items-center text-center"
|
||||
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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.it-list {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,117 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
useHead({ title: 'Intervento pubblico - Visione' })
|
||||
|
||||
const route = useRoute()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const token = computed(() => String(route.params.token || ''))
|
||||
const { getPublicIntervention } = useOperator()
|
||||
|
||||
const { data, pending, error } = await useAsyncData(
|
||||
`public-intervention-${token.value}`,
|
||||
() => getPublicIntervention(token.value),
|
||||
)
|
||||
|
||||
const payload = computed<any>(() => data.value || null)
|
||||
const report = computed<any>(() => payload.value?.report || payload.value?.summary || payload.value?.intervention || payload.value)
|
||||
const media = computed<any[]>(() => payload.value?.media || [])
|
||||
const messages = computed<any[]>(() => payload.value?.latest_messages || payload.value?.messages || [])
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container my-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div v-if="pending" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error || !report" class="card card-bg border-0 shadow-sm">
|
||||
<div class="card-body py-5 text-center">
|
||||
<BIIcon name="it-close-circle" size="lg" color="danger" class="mb-3" />
|
||||
<h1 class="h4 mb-2">Link non disponibile</h1>
|
||||
<p class="text-muted mb-0">Questo link di intervento non è valido, è scaduto oppure è stato revocato.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h1 class="h4 mb-2">Intervento pubblico</h1>
|
||||
<p class="text-muted mb-4">Scheda condivisa per team esterno.</p>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<small class="text-muted d-block">Segnalazione</small>
|
||||
<strong>#{{ report.id || '-' }}</strong>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<small class="text-muted d-block">Categoria</small>
|
||||
<strong>{{ report.category_name || '-' }}</strong>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<small class="text-muted d-block">Indirizzo</small>
|
||||
<strong>{{ report.address || '-' }}</strong>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<small class="text-muted d-block">Descrizione</small>
|
||||
<p class="mb-0">{{ report.description || '-' }}</p>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<small class="text-muted d-block">Stato</small>
|
||||
<strong>{{ report.status || '-' }}</strong>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<small class="text-muted d-block">Aperta</small>
|
||||
<strong>{{ formatDate(report.created_at) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="media.length > 0" class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Allegati</h2>
|
||||
<div class="row g-2">
|
||||
<div v-for="item in media" :key="item.id" class="col-6 col-md-4">
|
||||
<img
|
||||
v-if="item.media_kind === 'IMAGE'"
|
||||
:src="runtimeConfig.public.api + item.url"
|
||||
class="img-fluid rounded border"
|
||||
:alt="item.original_filename || 'Allegato'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="messages.length > 0" class="card card-bg border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Ultimi aggiornamenti</h2>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li v-for="message in messages" :key="message.id" class="border-bottom py-2">
|
||||
<div class="small text-muted mb-1">{{ formatDate(message.created_at) }}</div>
|
||||
<div>{{ message.body }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,69 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Note Legali - Visione',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="it-hero-wrapper it-primary">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<div class="it-hero-text-wrapper p-4">
|
||||
<h1 class="text-white font-weight-bold">Note Legali</h1>
|
||||
<p class="text-white mt-3 lead font-weight-semibold">
|
||||
Termini e condizioni di utilizzo della piattaforma Visione.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container my-5 py-4">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 offset-lg-2">
|
||||
|
||||
<h2 class="h3 font-weight-bold mb-3">1. Condizioni generali d'uso</h2>
|
||||
<p class="text-muted mb-4">
|
||||
Il presente documento definisce le condizioni generali di utilizzo del portale web "Visione" (di seguito, "Piattaforma"). L'accesso, la navigazione e l'utilizzo della Piattaforma comportano l'accettazione esplicita delle presenti note legali. Gli utenti sono invitati a leggere attentamente questo documento prima di utilizzare i servizi offerti.
|
||||
</p>
|
||||
|
||||
<h2 class="h3 font-weight-bold mb-3">2. Finalità della piattaforma</h2>
|
||||
<p class="text-muted mb-4">
|
||||
Visione è uno strumento digitale concepito per facilitare la comunicazione tra i cittadini e la Pubblica Amministrazione. Consente agli utenti registrati di inviare segnalazioni relative a disservizi urbani (es. buche, guasti all'illuminazione, rifiuti), allegando materiale fotografico e dati di geolocalizzazione, al fine di snellire i processi di intervento delle squadre comunali.
|
||||
</p>
|
||||
|
||||
<h2 class="h3 font-weight-bold mb-3">3. Proprietà intellettuale e Codice Sorgente</h2>
|
||||
<p class="text-muted mb-4">
|
||||
In un'ottica di totale trasparenza e indipendenza etica, il codice sorgente del progetto "Visione" è sviluppato e mantenuto su un'istanza self-hosted di <strong>Gitea</strong>. Tutti i diritti sui loghi, testi, e architettura del software, ove non diversamente specificato da licenze Open Source, appartengono ai rispettivi autori.
|
||||
È severamente vietato l'utilizzo di script automatizzati per l'estrazione massiva di dati dalla Piattaforma, ad eccezione degli endpoint ufficiali forniti nella sezione Open Data.
|
||||
</p>
|
||||
|
||||
<h2 class="h3 font-weight-bold mb-3">4. Limitazione di responsabilità</h2>
|
||||
<p class="text-muted mb-4">
|
||||
L'Amministrazione e gli sviluppatori si impegnano a garantire l'aggiornamento e l'accuratezza delle informazioni presenti sulla Piattaforma. Tuttavia, non si assumono alcuna responsabilità per eventuali ritardi, disservizi o per la mancata e/o ritardata risoluzione delle problematiche segnalate dagli utenti. Le segnalazioni inserite non hanno valore di denuncia formale alle Autorità di Pubblica Sicurezza. In caso di emergenze, i cittadini sono tenuti a contattare direttamente il Numero Unico Europeo per le Emergenze (112).
|
||||
</p>
|
||||
|
||||
<h2 class="h3 font-weight-bold mb-3">5. Moderazione e Abusi</h2>
|
||||
<p class="text-muted mb-5">
|
||||
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>
|
||||
|
||||
<div class="callout callout-highlight callout-info mt-5">
|
||||
<div class="callout-title">
|
||||
<BIIcon name="it-info-circle" />
|
||||
Disclaimer di Progetto Accademico
|
||||
</div>
|
||||
<p>
|
||||
"Visione" è un progetto software realizzato esclusivamente per finalità didattiche all'interno del corso di <em>Laboratorio di Programmazione</em> (Prof. Vessio) presso l'<strong>Università degli Studi di Bari "Aldo Moro"</strong>.
|
||||
<br>Autori: Renato Loiacono, Michelangelo Parisi e Gabriele Schiraldi. <br>I dati elaborati in questa istanza hanno scopo puramente dimostrativo e non costituiscono un reale servizio comunale attivo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,210 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Notification } from '~/types/api'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({ title: 'Notifiche - Visione' })
|
||||
|
||||
const { fetchNotifications, markNotificationRead, refreshUnreadNotifications } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const page = ref(1)
|
||||
const perPage = ref(15)
|
||||
|
||||
const { data: notificationsData, refresh: refreshNotifications } = await useAsyncData(
|
||||
'notifications',
|
||||
() => fetchNotifications({ page: page.value, per_page: perPage.value }),
|
||||
{ watch: [page] }
|
||||
)
|
||||
|
||||
const notifications = computed<Notification[]>(() => notificationsData.value?.notifications || [])
|
||||
const totalNotifications = computed(() => notificationsData.value?.notifications?.length || 0)
|
||||
const unreadCount = computed(() => notifications.value.filter((notification) => !notification.read_at).length)
|
||||
|
||||
const handleMarkAsRead = async (notificationId: number) => {
|
||||
try {
|
||||
await markNotificationRead(notificationId)
|
||||
await refreshNotifications()
|
||||
await refreshUnreadNotifications()
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as read:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (notification: Notification) => {
|
||||
if (!notification.read_at) {
|
||||
await handleMarkAsRead(notification.id)
|
||||
}
|
||||
|
||||
if (notification.report_id) {
|
||||
router.push(`/reports/${notification.report_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffMins < 1) return 'Proprio ora'
|
||||
if (diffMins < 60) return `${diffMins}m fa`
|
||||
if (diffHours < 24) return `${diffHours}h fa`
|
||||
if (diffDays < 7) return `${diffDays}d fa`
|
||||
|
||||
return date.toLocaleDateString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getNotificationIcon = (type: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
'report_created': 'it-pencil',
|
||||
'report_assigned': 'it-check-circle',
|
||||
'report_updated': 'it-refresh',
|
||||
'report_resolved': 'it-check-circle',
|
||||
'message_received': 'it-comment',
|
||||
'feedback_requested': 'it-star',
|
||||
'reward_earned': 'it-star',
|
||||
}
|
||||
return icons[type] || 'it-bell'
|
||||
}
|
||||
|
||||
const getNotificationColor = (type: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
'report_created': 'primary',
|
||||
'report_assigned': 'info',
|
||||
'report_updated': 'info',
|
||||
'report_resolved': 'success',
|
||||
'message_received': 'primary',
|
||||
'feedback_requested': 'warning',
|
||||
'reward_earned': 'warning',
|
||||
}
|
||||
return colors[type] || 'secondary'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-light border-bottom py-4 mb-4">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1 class="h3 font-weight-bold mb-2">
|
||||
<BIIcon name="it-bell" size="sm" color="primary" class="me-2" />
|
||||
Centro Notifiche
|
||||
</h1>
|
||||
<p class="text-muted mb-0">
|
||||
{{ unreadCount > 0 ? `Hai ${unreadCount} notifiche non lette` : 'Tutte le notifiche lette' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mb-5">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-10 offset-lg-1">
|
||||
<div v-if="notifications.length === 0" class="card card-bg border-0 shadow-sm">
|
||||
<div class="card-body py-5 text-center">
|
||||
<BIIcon name="it-bell" size="lg" color="secondary" class="mb-3 d-block mx-auto" />
|
||||
<h2 class="h5 mb-2">Nessuna notifica</h2>
|
||||
<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 v-else class="card card-bg border-0 shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="notification-row p-3"
|
||||
:class="{ 'notification-row-unread': !notification.read_at }"
|
||||
>
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex flex-wrap justify-content-between gap-2">
|
||||
<div>
|
||||
<h2 class="h6 mb-1">{{ notification.title }}</h2>
|
||||
<p class="mb-1 text-muted">{{ notification.body }}</p>
|
||||
<small class="text-muted">{{ formatDate(notification.created_at) }}</small>
|
||||
</div>
|
||||
<div v-if="!notification.read_at" class="flex-shrink-0">
|
||||
<span class="badge bg-primary">Nuovo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="!notification.read_at"
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
@click.stop="handleMarkAsRead(notification.id)"
|
||||
>
|
||||
Segna come letta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
v-if="notifications.length > 0"
|
||||
:current-page="page"
|
||||
:per-page="perPage"
|
||||
:total-items="totalNotifications"
|
||||
@page-change="page = $event"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-row + .notification-row {
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.notification-row-unread {
|
||||
border-left: 4px solid #0066cc;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,298 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { CircleMarker, LatLngBoundsExpression, Map as LeafletMap } from 'leaflet'
|
||||
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
|
||||
|
||||
const { getOpenDataReports } = usePublic()
|
||||
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(9)
|
||||
|
||||
const { data: openDataResponse, pending } = await useAsyncData(
|
||||
'open-data-reports',
|
||||
() => getOpenDataReports({ page: 1, per_page: 200 })
|
||||
)
|
||||
|
||||
const allVisibleReports = computed(() =>
|
||||
(openDataResponse.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude))
|
||||
)
|
||||
|
||||
const visibleReports = computed(() => {
|
||||
const start = (currentPage.value - 1) * perPage.value
|
||||
const end = start + perPage.value
|
||||
return allVisibleReports.value.slice(start, end)
|
||||
})
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
const mapVisibleReports = computed(() => allVisibleReports.value)
|
||||
|
||||
const currentView = ref<'grid' | 'map'>('grid')
|
||||
const mapContainer = ref<HTMLElement | null>(null)
|
||||
let leaflet: typeof import('leaflet') | null = null
|
||||
let map: LeafletMap | null = null
|
||||
let markers: CircleMarker[] = []
|
||||
|
||||
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' }
|
||||
}
|
||||
}
|
||||
|
||||
const loadLeaflet = async () => {
|
||||
if (leaflet) return leaflet
|
||||
leaflet = await import('leaflet')
|
||||
return leaflet
|
||||
}
|
||||
|
||||
const clearMarkers = () => {
|
||||
if (!map) return
|
||||
markers.forEach((marker) => marker.removeFrom(map!))
|
||||
markers = []
|
||||
}
|
||||
|
||||
const renderMapMarkers = async () => {
|
||||
if (!import.meta.client || !mapContainer.value) return
|
||||
|
||||
const L = await loadLeaflet()
|
||||
if (!map) return
|
||||
|
||||
clearMarkers()
|
||||
|
||||
if (mapVisibleReports.value.length === 0) {
|
||||
map.setView(BARI_CENTER, 12)
|
||||
return
|
||||
}
|
||||
|
||||
mapVisibleReports.value.forEach((report) => {
|
||||
const marker = L.circleMarker([report.latitude, report.longitude], {
|
||||
radius: 8,
|
||||
weight: 2,
|
||||
color: '#ffffff',
|
||||
fillColor: getStatusBadge(report.status).class === 'bg-success' ? '#008758' : getStatusBadge(report.status).class === 'bg-secondary' ? '#5c6f82' : '#2f80ed',
|
||||
fillOpacity: 0.95,
|
||||
})
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="open-data-popup">
|
||||
<strong>${report.category_name}</strong><br/>
|
||||
<span>ID #${report.id}</span><br/>
|
||||
<span>${report.area_name || 'Area comunale non specificata'}</span><br/>
|
||||
<span>Segnalato ${new Date(report.created_at).toLocaleDateString('it-IT')}</span>
|
||||
</div>
|
||||
`)
|
||||
|
||||
marker.addTo(map!)
|
||||
markers.push(marker)
|
||||
})
|
||||
|
||||
const fitBounds: LatLngBoundsExpression = mapVisibleReports.value.map((report) => [report.latitude, report.longitude])
|
||||
map.fitBounds(fitBounds, { padding: [32, 32], maxZoom: 15 })
|
||||
}
|
||||
|
||||
const initMap = async () => {
|
||||
if (!import.meta.client || !mapContainer.value || map) return
|
||||
|
||||
const L = await loadLeaflet()
|
||||
const bariBounds = L.latLngBounds(
|
||||
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||
)
|
||||
|
||||
map = L.map(mapContainer.value, {
|
||||
maxBounds: bariBounds,
|
||||
maxBoundsViscosity: 1,
|
||||
minZoom: 11,
|
||||
}).setView(BARI_CENTER, 12)
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map)
|
||||
|
||||
await renderMapMarkers()
|
||||
}
|
||||
|
||||
watch(currentView, async (view) => {
|
||||
if (view !== 'map') return
|
||||
await nextTick()
|
||||
if (!map) {
|
||||
await initMap()
|
||||
} else {
|
||||
map.invalidateSize()
|
||||
await renderMapMarkers()
|
||||
}
|
||||
})
|
||||
|
||||
watch(mapVisibleReports, async () => {
|
||||
if (currentView.value === 'map' && map) {
|
||||
await renderMapMarkers()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearMarkers()
|
||||
map?.remove()
|
||||
map = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="it-hero-wrapper it-primary">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<div class="it-hero-text-wrapper p-4">
|
||||
<h1 class="text-white font-weight-bold">Open Data Interventi</h1>
|
||||
<p class="text-white mt-2 lead">
|
||||
Esplora i dati pubblici degli interventi urbani risolti. La trasparenza al servizio del cittadino.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container my-5" id="grid-start">
|
||||
<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">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="currentView === 'grid' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="currentView = 'grid'"
|
||||
>
|
||||
<BIIcon :color="currentView === 'grid' ? 'white' : 'primary'" class="me-1" name="it-grid" size="sm"/>
|
||||
Griglia Dati
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="currentView === 'map' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="currentView = 'map'"
|
||||
>
|
||||
<BIIcon :color="currentView === 'map' ? 'white' : 'primary'" class="me-1" name="it-map-marker" size="sm"/>
|
||||
Mappa
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 text-md-end">
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Scarica dataset in formato JSON">
|
||||
<BIIcon class="me-1" name="it-download" size="sm"/>
|
||||
Scarica JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="d-flex justify-content-center my-5 py-5">
|
||||
<div class="progress-spinner progress-spinner-active" role="status">
|
||||
<span class="visually-hidden">Caricamento in corso...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!pending && currentView === 'grid'">
|
||||
<div class="row" v-if="allVisibleReports.length > 0">
|
||||
<div class="col-12 col-md-6 col-lg-4 mb-4" v-for="report in visibleReports" :key="report.id">
|
||||
<div class="card-wrapper card-space h-100">
|
||||
<div class="card card-bg h-100 shadow-sm">
|
||||
<div class="card-body d-flex flex-column">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div class="category-top m-0">
|
||||
<span :class="getStatusBadge(report.status).class"
|
||||
class="badge rounded-pill text-white px-3 py-1">
|
||||
<BIIcon :name="getStatusBadge(report.status).icon" class="me-1" color="white" size="xs"/>
|
||||
{{ getStatusBadge(report.status).text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 v-else class="alert alert-warning text-center" role="alert">
|
||||
Nessun dato pubblico disponibile al momento.
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
v-if="allVisibleReports.length > 0"
|
||||
:current-page="currentPage"
|
||||
:per-page="perPage"
|
||||
:total-items="allVisibleReports.length"
|
||||
@page-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="!pending && currentView === 'map'" class="map-view-wrapper">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
||||
<p class="text-muted mb-0">Mappa pubblica delle segnalazioni nell'area di Bari.</p>
|
||||
<span class="badge bg-light text-dark border">
|
||||
<template v-if="pending">Caricamento...</template>
|
||||
<template v-else>{{ mapVisibleReports.length }} punti</template>
|
||||
</span>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<div ref="mapContainer" class="open-data-map rounded border"></div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.open-data-map {
|
||||
height: 520px;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:global(.open-data-popup) {
|
||||
color: #17324d;
|
||||
font-family: "Titillium Web", Arial, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
min-width: 12rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
auth: { role: 'OPERATOR' }
|
||||
})
|
||||
|
||||
useHead({ title: 'Mie Assegnazioni - Operatore' })
|
||||
|
||||
const { getAssignedReports } = useOperator()
|
||||
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
|
||||
const { data: reportsRes, pending } = useAsyncData(
|
||||
'operator-assigned-reports',
|
||||
() => getAssignedReports({ page: page.value, per_page: perPage.value }),
|
||||
{ watch: [page] }
|
||||
)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container my-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 font-weight-bold mb-0">Le Mie Assegnazioni</h1>
|
||||
<div>
|
||||
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink>
|
||||
<NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Segnalazioni disponibili</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!reportsRes?.reports || reportsRes.reports.length === 0" class="alert alert-info">
|
||||
Non hai segnalazioni in carico al momento.
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<ReportTable
|
||||
:reports="reportsRes.reports"
|
||||
detail-base-path="/operator/reports"
|
||||
action-label="Apri segnalazione"
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
v-if="(reportsRes.total || 0) > perPage"
|
||||
:current-page="page"
|
||||
:per-page="perPage"
|
||||
:total-items="reportsRes.total!"
|
||||
@page-change="page = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
auth: { role: 'OPERATOR' }
|
||||
})
|
||||
|
||||
useHead({ title: 'Segnalazioni Aperte - Operatore' })
|
||||
|
||||
const { getOpenReports } = useOperator()
|
||||
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
|
||||
const { data: reportsRes, pending } = useAsyncData(
|
||||
'operator-open-reports',
|
||||
() => getOpenReports({ page: page.value, per_page: perPage.value }),
|
||||
{ watch: [page] }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container my-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 font-weight-bold mb-0">Segnalazioni da valutare</h1>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!reportsRes?.reports || reportsRes.reports.length === 0" class="alert alert-info">
|
||||
Non ci sono segnalazioni disponibili in questo momento.
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<ReportTable
|
||||
:reports="reportsRes.reports"
|
||||
detail-base-path="/operator/reports"
|
||||
action-label="Visualizza"
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
v-if="reportsRes.total > perPage"
|
||||
:current-page="page"
|
||||
:per-page="perPage"
|
||||
:total-items="reportsRes.total"
|
||||
@page-change="page = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
auth: { role: 'OPERATOR' }
|
||||
})
|
||||
|
||||
useHead({ title: 'Gestite da Altri - Operatore' })
|
||||
|
||||
const { getOtherOperatorReports } = useOperator()
|
||||
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
|
||||
const { data: reportsRes, pending } = useAsyncData(
|
||||
'operator-other-reports',
|
||||
() => getOtherOperatorReports({ page: page.value, per_page: perPage.value }),
|
||||
{ watch: [page] }
|
||||
)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container my-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 font-weight-bold mb-0">Gestite da altri Operatori</h1>
|
||||
<div>
|
||||
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink>
|
||||
<NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Segnalazioni disponibili</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!reportsRes?.reports || reportsRes.reports.length === 0" class="alert alert-info">
|
||||
Non ci sono segnalazioni gestite da altri.
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<ReportTable
|
||||
:reports="reportsRes.reports"
|
||||
detail-base-path="/operator/reports"
|
||||
action-label="Apri segnalazione"
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
v-if="reportsRes.total > perPage"
|
||||
:current-page="page"
|
||||
:per-page="perPage"
|
||||
:total-items="reportsRes.total"
|
||||
@page-change="page = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,947 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { FlagRequest, Message, Report, ReportStatus } from '~/types/api'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
auth: { role: 'OPERATOR' }
|
||||
})
|
||||
|
||||
useHead({ title: 'Gestione Segnalazione - Operatore' })
|
||||
|
||||
const route = useRoute()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const reportId = Number(route.params.id)
|
||||
|
||||
if (!reportId) {
|
||||
navigateTo('/operator')
|
||||
}
|
||||
|
||||
const { getReportDetail, getMessages, addMessage, flagMessage, flagReport, getTeamSuggestions } = useReports()
|
||||
const {
|
||||
takeReport,
|
||||
getInterventionSummary,
|
||||
updateReportStatus,
|
||||
assignTeam,
|
||||
getTeams,
|
||||
createInterventionLink,
|
||||
revokeInterventionLink
|
||||
} = useOperator()
|
||||
const { user } = useAuth()
|
||||
|
||||
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: summaryData, refresh: refreshSummary } = await useAsyncData(`op-summary-${reportId}`, () => getInterventionSummary(reportId))
|
||||
|
||||
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 statusHistory = computed(() => (reportData.value as any)?.history || [])
|
||||
const messages = computed<Message[]>(() => (messagesData.value as any)?.messages || [])
|
||||
const summary = computed(() => (summaryData.value as any)?.summary)
|
||||
const summaryMedia = computed(() => (summaryData.value as any)?.media || [])
|
||||
|
||||
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> = {
|
||||
OPEN: 'Aperta',
|
||||
UNDER_REVIEW: 'In revisione',
|
||||
ASSIGNED: 'Assegnata',
|
||||
IN_PROGRESS: 'In lavorazione',
|
||||
RESOLVED: 'Risolta',
|
||||
REJECTED: 'Respinta',
|
||||
CLOSED: 'Conclusa'
|
||||
}
|
||||
|
||||
const statusTransitions: Record<string, string[]> = {
|
||||
OPEN: ['UNDER_REVIEW', 'REJECTED'],
|
||||
UNDER_REVIEW: ['ASSIGNED', 'REJECTED'],
|
||||
ASSIGNED: ['IN_PROGRESS', 'REJECTED'],
|
||||
IN_PROGRESS: ['RESOLVED', 'REJECTED'],
|
||||
RESOLVED: ['CLOSED'],
|
||||
REJECTED: ['CLOSED'],
|
||||
CLOSED: []
|
||||
}
|
||||
|
||||
const availableStatuses: { value: ReportStatus, label: string }[] = [
|
||||
{ value: 'OPEN', label: 'Aperta' },
|
||||
{ value: 'UNDER_REVIEW', label: 'In Revisione' },
|
||||
{ value: 'ASSIGNED', label: 'Assegnata' },
|
||||
{ value: 'IN_PROGRESS', label: 'In Lavorazione' },
|
||||
{ value: 'RESOLVED', label: 'Risolta' },
|
||||
{ value: 'REJECTED', label: 'Rifiutata' },
|
||||
{ value: 'CLOSED', label: 'Conclusa' }
|
||||
]
|
||||
|
||||
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) => {
|
||||
if (val) {
|
||||
const allowed = statusTransitions[val] || []
|
||||
if (allowed.length > 0) {
|
||||
newStatus.value = allowed[0] as ReportStatus
|
||||
}
|
||||
}
|
||||
}, { 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 () => {
|
||||
if (!canChangeStatus.value) return
|
||||
isUpdatingStatus.value = true
|
||||
clearActionFeedback()
|
||||
try {
|
||||
const res = await updateReportStatus(reportId, {
|
||||
status: newStatus.value,
|
||||
note: statusNote.value || undefined
|
||||
})
|
||||
if (res.ok) {
|
||||
statusNote.value = ''
|
||||
await Promise.all([refreshReport(), refreshSummary()])
|
||||
actionSuccess.value = 'Stato aggiornato con successo.'
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Failed to update status', e)
|
||||
actionError.value = e?.data?.message || 'Aggiornamento stato non riuscito.'
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssignTeam = async () => {
|
||||
if (!canAssignTeam.value) return
|
||||
if (!selectedTeamId.value) return
|
||||
|
||||
isAssigning.value = true
|
||||
clearActionFeedback()
|
||||
try {
|
||||
const res = await assignTeam(reportId, {
|
||||
team_id: selectedTeamId.value
|
||||
})
|
||||
if (res.ok) {
|
||||
isAssignModalOpen.value = false
|
||||
selectedTeamId.value = null
|
||||
await Promise.all([refreshReport(), refreshSummary()])
|
||||
actionSuccess.value = 'Team assegnato con successo.'
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Failed to assign team', e)
|
||||
actionError.value = e?.data?.message || 'Assegnazione team non riuscita.'
|
||||
} finally {
|
||||
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[]) => {
|
||||
if (!canSendMessage.value) return
|
||||
if (!msgText.trim() && files.length === 0) return
|
||||
isSubmittingMessage.value = true
|
||||
clearActionFeedback()
|
||||
try {
|
||||
await addMessage(reportId, { body: msgText })
|
||||
await refreshMessages()
|
||||
actionSuccess.value = 'Messaggio inviato.'
|
||||
} catch (e: any) {
|
||||
console.error('Failed to send message', e)
|
||||
actionError.value = e?.data?.message || 'Invio messaggio non riuscito.'
|
||||
} finally {
|
||||
isSubmittingMessage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const canFlagMessage = (message: Message) => {
|
||||
if (!user.value) return false
|
||||
|
||||
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 = (message: Message) => {
|
||||
if (!canFlagMessage(message)) return
|
||||
|
||||
messageToFlag.value = message
|
||||
isFlagMessageModalOpen.value = true
|
||||
}
|
||||
|
||||
const executeFlagMessage = async () => {
|
||||
if (!messageToFlag.value) return
|
||||
|
||||
isFlaggingMessage.value = true
|
||||
try {
|
||||
await flagMessage(reportId, messageToFlag.value.id, {
|
||||
reason: 'Messaggio segnalato da operatore responsabile per verifica moderazione',
|
||||
})
|
||||
|
||||
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)
|
||||
actionError.value = e?.data?.message || 'Segnalazione messaggio non riuscita.'
|
||||
} finally {
|
||||
isFlaggingMessage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const executeFlagReport = async () => {
|
||||
if (!reportFlagForm.reason.trim()) {
|
||||
reportFlagError.value = 'Inserisci un motivo.'
|
||||
return
|
||||
}
|
||||
|
||||
isFlaggingReport.value = true
|
||||
reportFlagError.value = ''
|
||||
reportFlagSuccess.value = ''
|
||||
|
||||
try {
|
||||
await flagReport(reportId, {
|
||||
reason: reportFlagForm.reason.trim(),
|
||||
details: reportFlagForm.details.trim() || undefined
|
||||
})
|
||||
|
||||
reportFlagSuccess.value = 'Segnalazione inviata alla moderazione.'
|
||||
isFlagReportModalOpen.value = false
|
||||
resetReportFlagForm()
|
||||
} catch (e: any) {
|
||||
console.error('Failed to flag report', e)
|
||||
reportFlagError.value = e?.data?.message || 'Invio segnalazione non riuscito.'
|
||||
} finally {
|
||||
isFlaggingReport.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleDateString('it-IT', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const latestCitizenMessage = computed(() => {
|
||||
const reportUserId = report.value?.user_id
|
||||
if (!reportUserId) return null
|
||||
|
||||
return [...messages.value]
|
||||
.reverse()
|
||||
.find((message) => message.user_id === reportUserId) || null
|
||||
})
|
||||
|
||||
const formattedInterventionLinkExpiry = computed(() =>
|
||||
interventionLinkExpiresAt.value ? formatDate(interventionLinkExpiresAt.value) : ''
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="report">
|
||||
<div class="report-hero py-4">
|
||||
<div class="container">
|
||||
<NuxtLink :to="isTakenByCurrentOperator ? '/operator/assigned' : '/operator'" class="btn btn-sm btn-link text-decoration-none ps-0 mb-2">
|
||||
← Torna all'elenco
|
||||
</NuxtLink>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="report-hero-meta mb-2">
|
||||
<span class="report-id">Segnalazione #{{ reportId }}</span>
|
||||
<StatusBadge :status="report.status" />
|
||||
<span v-if="isReadOnlyPreview" class="badge bg-secondary">Sola lettura</span>
|
||||
</div>
|
||||
<h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1>
|
||||
<p class="mb-0 report-hero-address">{{ report.address }}</p>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="report-hero-stats">
|
||||
<div class="hero-stat">
|
||||
<span class="hero-stat-label">Stato</span>
|
||||
<strong>{{ statusMeta[report.status] || report.status }}</strong>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span class="hero-stat-label">Aperta</span>
|
||||
<strong>{{ formatDate(report.created_at || '') }}</strong>
|
||||
</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 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="col-12 col-xl-7">
|
||||
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="report-section-head mb-3">
|
||||
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
|
||||
<h2 class="h6 mb-0">Dettagli segnalazione</h2>
|
||||
</div>
|
||||
<dl class="report-detail-grid mb-0">
|
||||
<div>
|
||||
<dt>Indirizzo</dt>
|
||||
<dd>{{ report.address }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Coordinate</dt>
|
||||
<dd>{{ report.latitude }}, {{ report.longitude }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Area</dt>
|
||||
<dd>{{ report.area_name || 'Non specificata' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operatore assegnato</dt>
|
||||
<dd>{{ report.assigned_operator_id ? `#${report.assigned_operator_id}` : 'Non assegnato' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="report-description mt-4">
|
||||
<h3 class="h6 mb-2">Descrizione</h3>
|
||||
<p class="mb-0 text-break">{{ report.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="media.length > 0">
|
||||
<h3 class="h6 mt-4 mb-3">Allegati del cittadino</h3>
|
||||
<div class="row g-2">
|
||||
<div v-for="m in media" :key="m.id" class="col-4 col-md-3">
|
||||
<img v-if="m.media_kind === 'IMAGE'" :src="runtimeConfig.public.api + m.url" class="img-fluid rounded object-fit-cover shadow-sm report-media-frame" style="aspect-ratio: 1;" />
|
||||
</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 class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="report-chat-header mb-3">
|
||||
<div>
|
||||
<div class="report-section-head mb-1">
|
||||
<BIIcon name="it-comment" size="sm" color="primary" class="me-2" />
|
||||
<h2 class="h6 mb-0">Comunicazioni con cittadino</h2>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">Chat unica pratica. Mantieni aggiornamenti chiari e brevi.</p>
|
||||
</div>
|
||||
<span class="badge bg-light text-dark border">{{ messages.length }} messaggi</span>
|
||||
</div>
|
||||
<MessageList
|
||||
:messages="messages"
|
||||
:can-flag-message="canFlagMessage"
|
||||
:is-flagging-message-id="isFlaggingMessageId"
|
||||
@flag="handleFlagMessage"
|
||||
/>
|
||||
<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 class="col-12 col-xl-5">
|
||||
<div class="report-sidebar">
|
||||
<div class="card card-bg border-0 shadow-sm mb-4" v-if="summary">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Quadro operativo</h2>
|
||||
<ul class="report-summary-list list-unstyled mb-4">
|
||||
<li>
|
||||
<span>Operatore</span>
|
||||
<strong>{{ summary.assigned_operator_id ? `#${summary.assigned_operator_id}` : 'Nessuno' }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Team suggeriti</span>
|
||||
<strong :class="{'text-primary': suggestedTeams.length > 0}">{{ suggestedTeams.length }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Allegati</span>
|
||||
<strong>{{ summaryMedia.length || media.length }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="report-description">
|
||||
<h3 class="h6 mb-2">Ultimo messaggio cittadino</h3>
|
||||
<p class="small mb-0">
|
||||
{{ latestCitizenMessage?.body || 'Nessun messaggio del cittadino disponibile.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body pt-0" v-if="!isReadOnlyPreview">
|
||||
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="!canAssignTeam">
|
||||
Assegna team
|
||||
</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 class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<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">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nuovo Stato</label>
|
||||
<select class="form-select" v-model="newStatus">
|
||||
<option v-for="st in selectableStatuses" :key="st.value" :value="st.value">
|
||||
{{ st.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Nota (visibile nella cronologia)</label>
|
||||
<textarea class="form-control" v-model="statusNote" rows="2" placeholder="Motivazione o dettaglio..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100" :disabled="isUpdatingStatus">
|
||||
<span v-if="isUpdatingStatus" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Aggiorna Stato
|
||||
</button>
|
||||
</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 class="card card-bg border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Cronologia eventi</h2>
|
||||
<div v-if="statusHistory.length === 0" class="small text-muted">Nessun evento registrato.</div>
|
||||
<ul class="list-unstyled mb-0 ms-2">
|
||||
<li v-for="item in statusHistory" :key="item.id" class="mb-3 border-start border-primary border-3 ps-3">
|
||||
<small class="text-muted d-block">{{ formatDate(item.created_at) }}</small>
|
||||
<div class="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>
|
||||
<div v-if="item.note" class="small mt-1 bg-light p-1 rounded">{{ item.note }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BACKGROUND MODALE COMUNE -->
|
||||
<div v-if="isAssignModalOpen || isFlagReportModalOpen" class="modal-backdrop fade show d-block"
|
||||
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-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Gestione Intervento</h5>
|
||||
<button type="button" class="btn-close" @click="isAssignModalOpen = false"></button>
|
||||
</div>
|
||||
<!-- FORM AGGIORNATO CON MODAL-BODY E MODAL-FOOTER -->
|
||||
<form @submit.prevent="handleAssignTeam">
|
||||
<div class="modal-body">
|
||||
<div class="mb-0">
|
||||
<label class="form-label">Team incaricato</label>
|
||||
<select v-model.number="selectedTeamId" class="form-select" required>
|
||||
<option :value="null">Seleziona team</option>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<small v-if="allTeams.length === 0" class="form-text text-danger mt-1">
|
||||
Nessun team attivo disponibile.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
Salva Assegnazione
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
<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 v-else class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.report-hero {
|
||||
background: #f5f7f9;
|
||||
border-bottom: 1px solid #d9e3ee;
|
||||
}
|
||||
|
||||
.report-hero-meta,
|
||||
.report-section-head {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.report-hero-meta {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.report-id {
|
||||
color: #5c6f82;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.report-hero-address {
|
||||
color: #455b71;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.report-hero-stats {
|
||||
background: #fff;
|
||||
border: 1px solid #d9e3ee;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.hero-stat-label,
|
||||
.report-detail-grid dt {
|
||||
color: #5c6f82;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.report-detail-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.report-detail-grid dd {
|
||||
color: #17324d;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-description {
|
||||
background: #f5f7f9;
|
||||
border: 1px solid #e4ebf1;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.report-chat-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.report-summary-list {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.report-summary-list li {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.report-sidebar {
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
}
|
||||
|
||||
.report-media-frame {
|
||||
border: 1px solid #d9e3ee;
|
||||
}
|
||||
|
||||
.object-fit-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.report-detail-grid,
|
||||
.report-hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.report-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,83 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Privacy Policy - Visione',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="it-hero-wrapper it-primary">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<div class="it-hero-text-wrapper p-4">
|
||||
<h1 class="text-white font-weight-bold">Informativa sulla Privacy</h1>
|
||||
<p class="text-white mt-3 lead font-weight-semibold">
|
||||
Trattamento dei dati personali ai sensi del Regolamento (UE) 2016/679 (GDPR).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container my-5 py-4">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 offset-lg-2">
|
||||
|
||||
<div class="alert alert-info mb-5" role="alert">
|
||||
La presente informativa descrive le modalità di gestione della piattaforma <strong>Visione</strong> in riferimento al trattamento dei dati personali degli utenti che la consultano e vi si registrano.
|
||||
</div>
|
||||
|
||||
<h2 class="h3 font-weight-bold mb-3">1. Titolare del Trattamento</h2>
|
||||
<p class="text-muted mb-4">
|
||||
Ai fini dell'attuale istanza dimostrativa, il Titolare del trattamento dei dati coincide con gli sviluppatori del progetto afferenti all'Università degli Studi di Bari "Aldo Moro" (Progetto Accademico). In uno scenario di produzione, il Titolare del trattamento sarà il Comune o l'Ente della Pubblica Amministrazione che adotta e ospita il software.
|
||||
</p>
|
||||
|
||||
<h2 class="h3 font-weight-bold mb-3">2. Quali dati raccogliamo</h2>
|
||||
<p class="text-muted mb-2">Per garantire il corretto funzionamento della piattaforma e prevenire abusi, vengono raccolte le seguenti categorie di dati:</p>
|
||||
<ul class="mb-4 text-muted">
|
||||
<li><strong>Dati anagrafici e di contatto:</strong> Nome, cognome, Codice Fiscale, indirizzo email, numero di telefono (opzionale), data e luogo di nascita.</li>
|
||||
<li><strong>Dati di geolocalizzazione:</strong> Coordinate geografiche (Latitudine e Longitudine) e indirizzo associato alla segnalazione inviata dall'utente.</li>
|
||||
<li><strong>Contenuti multimediali:</strong> Fotografie e descrizioni testuali caricate spontaneamente dall'utente per documentare il disservizio.</li>
|
||||
<li><strong>Dati di navigazione:</strong> Indirizzi IP e timestamp generati automaticamente dai log di sistema per finalità di sicurezza e prevenzione frodi (accesso OTP).</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="h3 font-weight-bold mb-3">3. Finalità del Trattamento</h2>
|
||||
<p class="text-muted mb-4">
|
||||
I dati forniti verranno trattati esclusivamente per le seguenti finalità:
|
||||
<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>- 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.
|
||||
</p>
|
||||
|
||||
<h2 class="h3 font-weight-bold mb-3">4. Dati Pubblici e Portale Open Data</h2>
|
||||
<div class="card card-bg border-start border-info border-4 mb-4">
|
||||
<div class="card-body">
|
||||
<p class="mb-0 text-muted">
|
||||
Visione promuove la trasparenza amministrativa. Tutte le segnalazioni chiuse o risolte confluiscono nel nostro Portale Open Data.
|
||||
<strong>Prima della pubblicazione, i dati vengono severamente anonimizzati.</strong> Nessun dato personale (nome, cognome, codice fiscale o chat private) viene esposto. Vengono resi pubblici unicamente: Categoria dell'intervento, area geografica (es. quartiere), coordinate, e data di risoluzione.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="h3 font-weight-bold mb-3">5. Misure di Sicurezza</h2>
|
||||
<p class="text-muted mb-4">
|
||||
Il trattamento dei dati è realizzato tramite strumenti informatici e telematici, con logiche strettamente correlate alle finalità sopra evidenziate. Il backend (sviluppato in ambiente C++ ad alte prestazioni) adotta protocolli sicuri di trasmissione e tecniche di cifratura avanzata per lo stoccaggio delle credenziali.
|
||||
</p>
|
||||
|
||||
<h2 class="h3 font-weight-bold mb-3">6. Diritti dell'interessato</h2>
|
||||
<p class="text-muted mb-5">
|
||||
Ai sensi degli artt. 15-22 del GDPR, l'utente ha diritto di:
|
||||
<br>- Ottenere la conferma dell'esistenza di dati personali che lo riguardano.
|
||||
<br>- Chiedere la rettifica, l'aggiornamento o la cancellazione del proprio account e dei dati connessi.
|
||||
<br>- Opporti al trattamento o chiederne la limitazione.
|
||||
<br>Per esercitare tali diritti nell'ambito di questa istanza accademica, è possibile contattare gli sviluppatori o richiedere l'eliminazione direttamente dalla propria area riservata.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,162 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({ title: 'Profilo - Visione' })
|
||||
|
||||
const { user, fetchRewards, logout } = useAuth()
|
||||
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
|
||||
const { data: rewardsData } = await useAsyncData(
|
||||
'profile-rewards',
|
||||
() => fetchRewards({ page: page.value, per_page: perPage.value }),
|
||||
{ watch: [page] }
|
||||
)
|
||||
|
||||
const rewards = computed(() => rewardsData.value?.reward_events || [])
|
||||
const totalPoints = computed(() => rewardsData.value?.points || 0)
|
||||
const totalRewards = computed(() => rewards.value.length)
|
||||
|
||||
const formatDate = (dateString: string) =>
|
||||
new Date(dateString).toLocaleDateString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-light border-bottom py-4 mb-4">
|
||||
<div class="container">
|
||||
<h1 class="h3 font-weight-bold mb-1">Profilo</h1>
|
||||
<p class="text-muted mb-0">Dati account, punti cittadino e impostazioni personali.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mb-5">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card card-bg mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h5 card-title mb-0">Dati personali</h2>
|
||||
<NuxtLink to="/settings/profile" class="btn btn-outline-primary btn-sm">
|
||||
Modifica
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<small class="text-muted d-block">Nome</small>
|
||||
<strong>{{ user?.first_name }}</strong>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<small class="text-muted d-block">Cognome</small>
|
||||
<strong>{{ user?.last_name }}</strong>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<small class="text-muted d-block">Email</small>
|
||||
<strong>{{ user?.email }}</strong>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<small class="text-muted d-block">Telefono</small>
|
||||
<strong>{{ user?.phone || 'Non fornito' }}</strong>
|
||||
</div>
|
||||
<div class="col-md-6 mb-0">
|
||||
<small class="text-muted d-block">Codice fiscale</small>
|
||||
<strong>{{ user?.fiscal_code }}</strong>
|
||||
</div>
|
||||
<div class="col-md-6 mb-0">
|
||||
<small class="text-muted d-block">Ruolo</small>
|
||||
<span class="badge bg-info">{{ normalizeRole(user?.role) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-bg">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h5 card-title mb-0">Cronologia punti</h2>
|
||||
<span class="badge bg-primary">{{ totalPoints }} punti</span>
|
||||
</div>
|
||||
|
||||
<div v-if="rewards.length === 0" class="alert alert-info mb-0" role="alert">
|
||||
Nessun punto registrato.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col">Data</th>
|
||||
<th scope="col">Segnalazione</th>
|
||||
<th scope="col">Motivo</th>
|
||||
<th scope="col" class="text-end">Punti</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="reward in rewards" :key="reward.id">
|
||||
<td class="text-muted">{{ formatDate(reward.created_at) }}</td>
|
||||
<td>
|
||||
<NuxtLink :to="`/reports/${reward.report_id}`">#{{ reward.report_id }}</NuxtLink>
|
||||
</td>
|
||||
<td>{{ reward.reason }}</td>
|
||||
<td class="text-end">
|
||||
<span class="badge bg-success">+{{ reward.points }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="totalRewards > perPage"
|
||||
:current-page="page"
|
||||
:per-page="perPage"
|
||||
:total-items="totalRewards"
|
||||
@page-change="page = $event"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card card-bg mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 card-title mb-3">Azioni account</h2>
|
||||
<div class="d-grid gap-2">
|
||||
<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>
|
||||
<button type="button" class="btn btn-outline-danger" @click="logout">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-bg">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 card-title mb-2">Punti cittadino</h2>
|
||||
<div class="display-6 text-primary font-weight-bold mb-2">{{ totalPoints }}</div>
|
||||
<p class="small text-muted mb-0">
|
||||
I punti vengono accreditati sulle segnalazioni concluse con esito positivo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.card .card-body .d-grid > a {
|
||||
padding-block: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,487 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({ title: 'Dettaglio Segnalazione - Visione' })
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const { getReportDetail, getMessages, addMessage, addFeedback } = useReports()
|
||||
const { user } = useAuth()
|
||||
|
||||
const reportId = Number(route.params.id)
|
||||
if (!reportId) {
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
const { data: reportData, refresh: refreshReport } = await useAsyncData(`report-${reportId}`, () =>
|
||||
getReportDetail(reportId)
|
||||
)
|
||||
|
||||
const { data: messagesData, refresh: refreshMessages } = await useAsyncData(`messages-${reportId}`, () =>
|
||||
getMessages(reportId)
|
||||
)
|
||||
|
||||
const report = computed(() => (reportData.value as any)?.report)
|
||||
const media = computed(() => (reportData.value as any)?.media || [])
|
||||
const statusHistory = computed(() => (reportData.value as any)?.history || [])
|
||||
const messages = computed(() => (messagesData.value as any)?.messages || [])
|
||||
|
||||
const isSubmittingMessage = ref(false)
|
||||
|
||||
const isFeedbackOpen = ref(false)
|
||||
const feedbackRating = ref(5)
|
||||
const feedbackComment = ref('')
|
||||
const isSubmittingFeedback = ref(false)
|
||||
const submittedFeedback = ref<{ rating: number; comment?: string } | null>(null)
|
||||
|
||||
// Carica l'eventuale feedback da local storage dopo il mount (per evitare mismatch nel SSR)
|
||||
onMounted(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(`feedback_${reportId}`)
|
||||
if (saved) {
|
||||
submittedFeedback.value = JSON.parse(saved)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore nel recupero del feedback da local storage:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleSendMessage = async (messageText: string, files: File[]) => {
|
||||
isSubmittingMessage.value = true
|
||||
try {
|
||||
await addMessage(reportId, { body: messageText })
|
||||
|
||||
await refreshMessages()
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error)
|
||||
} finally {
|
||||
isSubmittingMessage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitFeedback = async () => {
|
||||
isSubmittingFeedback.value = true
|
||||
try {
|
||||
const feedbackData = {
|
||||
rating: feedbackRating.value,
|
||||
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
|
||||
await refreshReport()
|
||||
} catch (error) {
|
||||
console.error('Error submitting feedback:', error)
|
||||
} finally {
|
||||
isSubmittingFeedback.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isOwner = computed(() => report.value?.user_id === user.value?.id)
|
||||
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 }> = {
|
||||
OPEN: { label: 'Aperta', tone: 'info' },
|
||||
UNDER_REVIEW: { label: 'In revisione', tone: 'warning' },
|
||||
ASSIGNED: { label: 'Assegnata', tone: 'info' },
|
||||
IN_PROGRESS: { label: 'In lavorazione', tone: 'primary' },
|
||||
RESOLVED: { label: 'Risolta', tone: 'success' },
|
||||
REJECTED: { label: 'Respinta', tone: 'danger' },
|
||||
CLOSED: { label: 'Conclusa', tone: 'secondary' },
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="report">
|
||||
<div class="report-hero py-4 py-lg-5">
|
||||
<div class="container">
|
||||
<div class="row align-items-end g-3">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="report-hero-meta mb-2">
|
||||
<span class="report-id">Segnalazione #{{ report.id }}</span>
|
||||
<StatusBadge :status="report.status"/>
|
||||
</div>
|
||||
<h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1>
|
||||
<p class="mb-0 report-hero-address">{{ report.address }}</p>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="report-hero-stats">
|
||||
<div class="hero-stat">
|
||||
<span class="hero-stat-label">Stato</span>
|
||||
<strong>{{ statusMeta[report.status]?.label || report.status }}</strong>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span class="hero-stat-label">Creata</span>
|
||||
<strong>{{ formatDate(report.created_at) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container my-5">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="report-section-head mb-3">
|
||||
<BIIcon class="me-2" color="primary" name="it-map-marker-circle" size="sm"/>
|
||||
<h2 class="h6 mb-0">Posizione e dettagli</h2>
|
||||
</div>
|
||||
<dl class="report-detail-grid mb-0">
|
||||
<div>
|
||||
<dt>Indirizzo</dt>
|
||||
<dd>{{ report.address }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Coordinate</dt>
|
||||
<dd>{{ report.latitude }}, {{ report.longitude }}</dd>
|
||||
</div>
|
||||
<div v-if="report.area_name">
|
||||
<dt>Area</dt>
|
||||
<dd>{{ report.area_name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ultimo aggiornamento</dt>
|
||||
<dd>{{ formatDate(report.updated_at || report.created_at) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="report-description mt-4">
|
||||
<h3 class="h6 mb-2">Descrizione</h3>
|
||||
<p class="mb-0 text-break">{{ report.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="media.length > 0" class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="report-section-head mb-3">
|
||||
<BIIcon class="me-2" color="primary" name="it-camera" size="sm"/>
|
||||
<h2 class="h6 mb-0">Allegati ({{ media.length }})</h2>
|
||||
</div>
|
||||
<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-if="m.media_kind === 'IMAGE'"
|
||||
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 v-else
|
||||
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 class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Cronologia eventi</h2>
|
||||
<div v-if="statusHistory.length === 0" class="small text-muted">Nessun evento registrato.</div>
|
||||
<ul class="list-unstyled mb-0 ms-2">
|
||||
<li v-for="item in statusHistory" :key="item.id" class="mb-3 border-start border-primary border-3 ps-3">
|
||||
<small class="text-muted d-block">{{ formatDate(item.created_at) }}</small>
|
||||
<div class="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>
|
||||
<div v-if="item.note" class="small mt-1 bg-light p-1 rounded">{{ item.note }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-bg border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="report-chat-header mb-3">
|
||||
<div>
|
||||
<div class="report-section-head mb-1">
|
||||
<BIIcon class="me-2" color="primary" name="it-comment" size="sm"/>
|
||||
<h2 class="h6 mb-0">Chat segnalazione</h2>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">Usa chat per chiarimenti, aggiornamenti operativi e richieste
|
||||
rapide.</p>
|
||||
</div>
|
||||
<span class="badge bg-light text-dark border">{{ messages.length }} messaggi</span>
|
||||
</div>
|
||||
|
||||
<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 class="col-12 col-xl-4">
|
||||
<div class="report-sidebar">
|
||||
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Riepilogo pratica</h2>
|
||||
<ul class="report-summary-list list-unstyled mb-0">
|
||||
<li>
|
||||
<span>Cittadino proprietario</span>
|
||||
<strong>{{ isOwner ? 'Sì' : 'No' }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Stato corrente</span>
|
||||
<strong>{{ statusMeta[report.status]?.label || report.status }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Canale</span>
|
||||
<strong>Portale Visione</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canFeedback && isOwner" class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<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>
|
||||
<button
|
||||
v-if="!isFeedbackOpen"
|
||||
type="button"
|
||||
class="btn btn-primary w-100"
|
||||
@click="isFeedbackOpen = true"
|
||||
>
|
||||
Valuta intervento
|
||||
</button>
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Valutazione</label>
|
||||
<div class="d-flex gap-2">
|
||||
<button v-for="n in 5" :key="n" type="button" class="btn btn-link p-0"
|
||||
@click="feedbackRating = n" :aria-label="`${n} stelle`">
|
||||
<BIIcon
|
||||
:name="n <= feedbackRating ? 'it-star-full' : 'it-star-outline'"
|
||||
size="lg"
|
||||
:color="n <= feedbackRating ? 'warning' : 'primary'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<textarea
|
||||
v-model="feedbackComment"
|
||||
class="form-control"
|
||||
placeholder="Commento opzionale"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button :disabled="isSubmittingFeedback" class="btn btn-primary" type="button"
|
||||
@click="handleSubmitFeedback">
|
||||
<span v-if="isSubmittingFeedback" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Invia
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @click="isFeedbackOpen = false">
|
||||
Annulla
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="container my-5 text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Caricamento...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.report-hero {
|
||||
background: #f5f7f9;
|
||||
border-bottom: 1px solid #d9e3ee;
|
||||
}
|
||||
|
||||
.report-hero-meta,
|
||||
.report-section-head {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.report-hero-meta {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.report-id {
|
||||
color: #5c6f82;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.report-hero-address {
|
||||
color: #455b71;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.report-hero-stats {
|
||||
background: #fff;
|
||||
border: 1px solid #d9e3ee;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.hero-stat-label,
|
||||
.report-detail-grid dt {
|
||||
color: #5c6f82;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.report-detail-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.report-detail-grid dd {
|
||||
color: #17324d;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-description {
|
||||
background: #f5f7f9;
|
||||
border: 1px solid #e4ebf1;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.report-media-frame {
|
||||
border: 1px solid #d9e3ee;
|
||||
}
|
||||
|
||||
.report-chat-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.report-sidebar {
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
}
|
||||
|
||||
.report-summary-list {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.report-summary-list li {
|
||||
align-items: baseline;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ratio {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.object-fit-cover {
|
||||
object-fit: cover !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.report-detail-grid,
|
||||
.report-hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.report-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,118 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Report } from '~/types/api'
|
||||
import { isWithinBariBounds } from '~/utils/bari'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({ title: 'Le mie segnalazioni - Visione' })
|
||||
|
||||
const { getReports } = useReports()
|
||||
|
||||
const page = ref(1)
|
||||
const perPage = ref(10)
|
||||
const filterStatus = ref('')
|
||||
|
||||
const { data: reportsRes, pending } = useAsyncData('citizen-reports-list', () => getReports({}))
|
||||
|
||||
const reports = computed<Report[]>(() =>
|
||||
(reportsRes.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude))
|
||||
)
|
||||
const filteredReports = computed(() => {
|
||||
if (!filterStatus.value) return reports.value
|
||||
if (filterStatus.value === 'IN_PROGRESS') {
|
||||
return reports.value.filter((report) => report.status === 'IN_PROGRESS' || report.status === 'ASSIGNED')
|
||||
}
|
||||
return reports.value.filter((report) => report.status === filterStatus.value)
|
||||
})
|
||||
|
||||
const paginatedReports = computed(() => {
|
||||
const start = (page.value - 1) * perPage.value
|
||||
return filteredReports.value.slice(start, start + perPage.value)
|
||||
})
|
||||
|
||||
const handleStatusFilter = (status: string) => {
|
||||
filterStatus.value = status === 'all' ? '' : status
|
||||
page.value = 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-light border-bottom py-4 mb-4">
|
||||
<div class="container">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3">
|
||||
<div>
|
||||
<h1 class="h3 font-weight-bold mb-1">Le mie segnalazioni</h1>
|
||||
<p class="text-muted mb-0">Consulta stato, cronologia e conversazioni con gli operatori.</p>
|
||||
</div>
|
||||
<NuxtLink to="/reports/new" class="btn btn-primary">
|
||||
<BIIcon name="it-plus-circle" size="sm" color="white" class="me-2" />
|
||||
Nuova segnalazione
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mb-5">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
|
||||
<div class="btn-group" role="group" aria-label="Filtri segnalazioni">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="filterStatus === '' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="handleStatusFilter('all')"
|
||||
>
|
||||
Tutte
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="filterStatus === 'OPEN' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="handleStatusFilter('OPEN')"
|
||||
>
|
||||
Aperte
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="filterStatus === 'IN_PROGRESS' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="handleStatusFilter('IN_PROGRESS')"
|
||||
>
|
||||
In corso
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="filterStatus === 'RESOLVED' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="handleStatusFilter('RESOLVED')"
|
||||
>
|
||||
Risolte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Caricamento...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredReports.length === 0" class="alert alert-info" role="alert">
|
||||
Nessuna segnalazione trovata.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<ReportTable :reports="paginatedReports" action-label="Apri segnalazione" />
|
||||
<Pagination
|
||||
v-if="filteredReports.length > perPage"
|
||||
:current-page="page"
|
||||
:per-page="perPage"
|
||||
:total-items="filteredReports.length"
|
||||
@page-change="page = $event"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,82 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { CreateReportRequest } from '~/types/api'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
auth: {
|
||||
role: ['!ADMIN', '!OPERATOR']
|
||||
}
|
||||
})
|
||||
|
||||
useHead({ title: 'Nuova segnalazione - Visione' })
|
||||
|
||||
const router = useRouter()
|
||||
const { createReport, uploadMedia, getCategories } = useReports()
|
||||
const { data: categoriesData } = await useAsyncData('categories', () => getCategories())
|
||||
const categories = computed(() => categoriesData.value?.categories || [])
|
||||
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const handleReportSubmit = async (reportData: CreateReportRequest, mediaFiles: File[]) => {
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await createReport({
|
||||
...reportData,
|
||||
media: [],
|
||||
})
|
||||
|
||||
if (response.ok && response.report_id) {
|
||||
if (mediaFiles.length > 0) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
mediaFiles.forEach((file) => formData.append('files', file))
|
||||
await uploadMedia(response.report_id, formData)
|
||||
} catch (error) {
|
||||
console.error('Media upload failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
await router.push(`/reports/${response.report_id}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.data?.message || 'Errore durante la creazione della segnalazione. Riprova.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="it-hero-wrapper it-primary-bg hero-compact">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="it-hero-text-wrapper p-4">
|
||||
<h1 class="text-white font-weight-bold">Nuova segnalazione</h1>
|
||||
<p class="text-white mt-2 lead">
|
||||
Descrivi il problema urbano, indica la posizione e allega eventuali media.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container my-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-xl-10">
|
||||
<div v-if="errorMessage" class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
|
||||
{{ errorMessage }}
|
||||
<button type="button" class="btn-close" aria-label="Chiudi" @click="errorMessage = ''"></button>
|
||||
</div>
|
||||
|
||||
<ReportForm :categories="categories" :is-loading="isLoading" @submit="handleReportSubmit" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,199 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
useHead({ title: 'Modifica Profilo - Visione' })
|
||||
|
||||
const { user, updateProfile, syncUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const formData = reactive({
|
||||
first_name: user.value?.first_name || '',
|
||||
last_name: user.value?.last_name || '',
|
||||
phone: user.value?.phone || ''
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return formData.first_name.trim().length > 0 && formData.last_name.trim().length > 0
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
const res = await updateProfile({
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
phone: formData.phone || undefined
|
||||
})
|
||||
|
||||
if (res.ok || res.user) {
|
||||
await syncUser()
|
||||
successMessage.value = 'Profilo aggiornato con successo.'
|
||||
setTimeout(() => {
|
||||
router.push('/profile')
|
||||
}, 1500)
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.data?.message || 'Errore durante l\'aggiornamento del profilo.'
|
||||
} 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 class="card card-bg border-0 shadow-sm">
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<NuxtLink to="/profile" class="btn btn-sm btn-link text-decoration-none ps-0 me-3">
|
||||
<BIIcon name="it-arrow-left" size="sm" />
|
||||
</NuxtLink>
|
||||
<h1 class="h3 mb-0">Modifica Profilo</h1>
|
||||
</div>
|
||||
<p class="text-muted mb-4">
|
||||
Aggiorna i tuoi dati personali. Email e Codice Fiscale non sono modificabili.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="d-grid gap-4">
|
||||
<div class="input-field">
|
||||
<label for="first_name" class="form-label">Nome</label>
|
||||
<div class="input-field-control">
|
||||
<BIIcon name="it-user" size="sm" class="input-field-icon" />
|
||||
<input
|
||||
id="first_name"
|
||||
v-model="formData.first_name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-field">
|
||||
<label for="last_name" class="form-label">Cognome</label>
|
||||
<div class="input-field-control">
|
||||
<BIIcon name="it-user" size="sm" class="input-field-icon" />
|
||||
<input
|
||||
id="last_name"
|
||||
v-model="formData.last_name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-field">
|
||||
<label for="phone" class="form-label">Telefono (opzionale)</label>
|
||||
<div class="input-field-control">
|
||||
<BIIcon name="it-telephone" size="sm" class="input-field-icon" />
|
||||
<input
|
||||
id="phone"
|
||||
v-model="formData.phone"
|
||||
type="tel"
|
||||
class="form-control"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-field opacity-50">
|
||||
<label class="form-label">Email</label>
|
||||
<div class="input-field-control">
|
||||
<BIIcon name="it-mail" size="sm" class="input-field-icon" />
|
||||
<input
|
||||
type="email"
|
||||
class="form-control disabled"
|
||||
:value="user?.email"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-field opacity-50">
|
||||
<label class="form-label">Codice Fiscale</label>
|
||||
<div class="input-field-control">
|
||||
<BIIcon name="it-card" size="sm" class="input-field-icon" />
|
||||
<input
|
||||
type="text"
|
||||
class="form-control disabled"
|
||||
:value="user?.fiscal_code"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="alert alert-danger mb-0" role="alert">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="successMessage" class="alert alert-success mb-0" role="alert">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-100 py-2 mt-2"
|
||||
:disabled="!canSubmit || isLoading"
|
||||
>
|
||||
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
|
||||
{{ isLoading ? 'Aggiornamento in corso...' : 'Salva modifiche' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input-field {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-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);
|
||||
padding-left: 0.875rem;
|
||||
}
|
||||
|
||||
.input-field-control:focus-within {
|
||||
border-color: #06c;
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.16);
|
||||
}
|
||||
|
||||
.input-field-icon {
|
||||
color: #5c6f82;
|
||||
}
|
||||
|
||||
.input-field-control .form-control {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
min-width: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const auth = useAuth()
|
||||
const currentPath = useNuxtApp().$router.currentRoute.value.path
|
||||
|
||||
if ((auth.token.value || auth.tokenState.value) && !auth.user.value) {
|
||||
try {
|
||||
await auth.syncUser({ currentPath })
|
||||
} catch (error) {
|
||||
const status = (error as any)?.statusCode || (error as any)?.status || (error as any)?.response?.status
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
console.error('[Auth Plugin] Sync failed with authentication error, keeping session state:', error)
|
||||
return
|
||||
}
|
||||
|
||||
console.error('[Auth Plugin] Sync failed:', error)
|
||||
auth.logout()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
// @ts-expect-error
|
||||
await import('bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js')
|
||||
})
|
||||
@@ -4,451 +4,406 @@ export type FlagStatus = 'OPEN' | 'RESOLVED' | 'DISMISSED';
|
||||
export type FlagContentType = 'REPORT' | 'MESSAGE';
|
||||
|
||||
export interface GeoJsonObject {
|
||||
type: string;
|
||||
coordinates: any[];
|
||||
|
||||
[key: string]: any;
|
||||
type: string;
|
||||
coordinates: any[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
message?: string;
|
||||
fields?: string[];
|
||||
error: string;
|
||||
message?: string;
|
||||
fields?: string[];
|
||||
}
|
||||
|
||||
export interface OkResponse {
|
||||
ok: boolean;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* USER & AUTHENTICATION
|
||||
*/
|
||||
export interface CurrentUser {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
fiscal_code: string;
|
||||
points: number;
|
||||
role: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
phone?: string;
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
fiscal_code: string;
|
||||
points: number;
|
||||
must_change_password: boolean;
|
||||
role: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
fiscal_code: string;
|
||||
phone?: string;
|
||||
banned?: boolean;
|
||||
points?: number;
|
||||
roles?: string;
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
fiscal_code: string;
|
||||
phone?: string;
|
||||
banned?: boolean;
|
||||
points?: number;
|
||||
must_change_password?: boolean;
|
||||
roles?: string; // Comma-separated
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
ok: boolean;
|
||||
token: string;
|
||||
token_type: string;
|
||||
expires_in_hours: number;
|
||||
}
|
||||
|
||||
export interface CieStartRequest {
|
||||
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;
|
||||
ok: boolean;
|
||||
token: string;
|
||||
token_type: string;
|
||||
expires_in_hours: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* REPORTS & MEDIA
|
||||
*/
|
||||
export interface Report {
|
||||
id: number;
|
||||
category_id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
description: string;
|
||||
status: ReportStatus;
|
||||
area_id?: number;
|
||||
area_name?: string;
|
||||
category_name?: string;
|
||||
assigned_operator_id?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
user_id?: number;
|
||||
id: number;
|
||||
category_id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
description: string;
|
||||
status: ReportStatus;
|
||||
area_id?: number;
|
||||
area_name?: string;
|
||||
category_name?: string;
|
||||
assigned_operator_id?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
export interface OpenDataReport {
|
||||
id: number;
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
status: ReportStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
area_id?: number | null;
|
||||
area_name?: string | null;
|
||||
id: number;
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
status: ReportStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
area_id?: number | null;
|
||||
area_name?: string | null;
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
id: number;
|
||||
report_id: number;
|
||||
url: string;
|
||||
media_kind: MediaKind;
|
||||
content_type: string;
|
||||
original_filename: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
id: number;
|
||||
report_id: number;
|
||||
url: string;
|
||||
media_kind: MediaKind;
|
||||
content_type: string;
|
||||
original_filename: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StatusHistoryItem {
|
||||
id: number;
|
||||
report_id: number;
|
||||
changed_by_user_id: number;
|
||||
old_status: ReportStatus;
|
||||
new_status: ReportStatus;
|
||||
note?: string;
|
||||
created_at: string;
|
||||
id: number;
|
||||
report_id: number;
|
||||
changed_by_user_id: number;
|
||||
old_status: ReportStatus;
|
||||
new_status: ReportStatus;
|
||||
note?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
report_id: number;
|
||||
user_id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
id: number;
|
||||
report_id: number;
|
||||
user_id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ENTITIES (Areas, Teams, Categories, Notifications, Rewards, Moderation)
|
||||
*/
|
||||
export interface Area {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
boundary_geojson?: GeoJsonObject;
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
boundary_geojson?: GeoJsonObject;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
team_category_id: number;
|
||||
area_id?: number;
|
||||
active?: boolean;
|
||||
area_name?: string;
|
||||
category_name?: string;
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
team_category_id: number;
|
||||
area_id?: number;
|
||||
active?: boolean;
|
||||
area_name?: string;
|
||||
category_name?: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
report_id?: number | null;
|
||||
read_at?: string | null;
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
report_id?: number | null;
|
||||
read_at?: string | null;
|
||||
}
|
||||
|
||||
export interface RewardEvent {
|
||||
id: number;
|
||||
report_id: number;
|
||||
points: number;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
id: number;
|
||||
report_id: number;
|
||||
points: number;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ModerationFlag {
|
||||
id: number;
|
||||
report_id: number;
|
||||
message_id?: number | null;
|
||||
reason: string;
|
||||
details?: string;
|
||||
status: FlagStatus;
|
||||
content_type: FlagContentType;
|
||||
created_by_user_id: number;
|
||||
created_by_email: string;
|
||||
created_at: string;
|
||||
resolved_by_user_id?: number | null;
|
||||
resolved_by_email?: string | null;
|
||||
resolution_note?: string | null;
|
||||
resolved_at?: string | null;
|
||||
id: number;
|
||||
report_id: number;
|
||||
message_id?: number | null;
|
||||
reason: string;
|
||||
details?: string;
|
||||
status: FlagStatus;
|
||||
content_type: FlagContentType;
|
||||
created_by_user_id: number;
|
||||
created_by_email: string;
|
||||
created_at: string;
|
||||
resolved_by_user_id?: number | null;
|
||||
resolved_by_email?: string | null;
|
||||
resolution_note?: string | null;
|
||||
resolved_at?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* REQUEST BODIES
|
||||
*/
|
||||
export interface RegisterCitizenRequest {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
fiscal_code: string;
|
||||
phone?: string;
|
||||
birth_date?: string;
|
||||
birth_place?: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
fiscal_code: string;
|
||||
phone?: string;
|
||||
birth_date?: string;
|
||||
birth_place?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
export interface PasswordLoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export interface EmailRequest {
|
||||
email: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface VerifyOtpRequest {
|
||||
email: string;
|
||||
code: string;
|
||||
email: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface CreateReportRequest {
|
||||
category_id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
description: string;
|
||||
media?: string[];
|
||||
category_id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string;
|
||||
description: string;
|
||||
media?: string[]; // Array of URLs if already uploaded
|
||||
}
|
||||
|
||||
export interface UpdateStatusRequest {
|
||||
status: ReportStatus;
|
||||
note?: string;
|
||||
status: ReportStatus;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface AssignTeamRequest {
|
||||
team_id: number;
|
||||
team_id: number;
|
||||
}
|
||||
|
||||
export interface MessageRequest {
|
||||
body: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface FeedbackRequest {
|
||||
rating: number;
|
||||
comment?: string;
|
||||
rating: number; // 1 to 5
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface FlagRequest {
|
||||
reason: string;
|
||||
details?: string;
|
||||
reason: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface UpdateModerationRequest {
|
||||
status: 'RESOLVED' | 'DISMISSED';
|
||||
reject_report?: boolean;
|
||||
resolution_note?: string;
|
||||
status: 'RESOLVED' | 'DISMISSED';
|
||||
reject_report?: boolean;
|
||||
resolution_note?: string;
|
||||
}
|
||||
|
||||
export interface CreateAreaRequest {
|
||||
name: string;
|
||||
boundary_geojson: GeoJsonObject;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAreaRequest {
|
||||
name?: string;
|
||||
boundary_geojson?: GeoJsonObject;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
name: string;
|
||||
boundary_geojson: GeoJsonObject;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateTeamRequest {
|
||||
team_category_id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
area_id?: number;
|
||||
}
|
||||
|
||||
export interface UpdateTeamRequest {
|
||||
team_category_id?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
area_id?: number | null;
|
||||
active?: boolean;
|
||||
team_category_id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
area_id?: number;
|
||||
}
|
||||
|
||||
export interface UpdateUserBanRequest {
|
||||
banned: boolean;
|
||||
banned: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserRolesRequest {
|
||||
roles: string[];
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* RESPONSE BODIES
|
||||
*/
|
||||
export interface CurrentUserResponse {
|
||||
ok: boolean;
|
||||
user: CurrentUser;
|
||||
ok: boolean;
|
||||
user: CurrentUser;
|
||||
}
|
||||
|
||||
export interface RegisterCitizenResponse {
|
||||
ok: boolean;
|
||||
user_id: number;
|
||||
ok: boolean;
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
export interface OtpRequestResponse {
|
||||
ok: boolean;
|
||||
delivery: string;
|
||||
expires_in_minutes: number;
|
||||
ok: boolean;
|
||||
delivery: string;
|
||||
expires_in_minutes: number;
|
||||
}
|
||||
|
||||
export interface CreateReportResponse {
|
||||
ok: boolean;
|
||||
report_id: number;
|
||||
area_id?: number;
|
||||
ok: boolean;
|
||||
report_id: number;
|
||||
area_id?: number;
|
||||
}
|
||||
|
||||
export interface ReportListResponse {
|
||||
ok: boolean;
|
||||
reports: Report[];
|
||||
ok: boolean;
|
||||
reports: Report[];
|
||||
}
|
||||
|
||||
export interface PagedReportListResponse {
|
||||
ok: boolean;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total?: number;
|
||||
reports: Report[];
|
||||
ok: boolean;
|
||||
page: number;
|
||||
per_page: number;
|
||||
reports: Report[];
|
||||
}
|
||||
|
||||
export interface OpenDataReportListResponse {
|
||||
ok: boolean;
|
||||
page: number;
|
||||
per_page: number;
|
||||
reports: OpenDataReport[];
|
||||
ok: boolean;
|
||||
page: number;
|
||||
per_page: number;
|
||||
reports: OpenDataReport[];
|
||||
}
|
||||
|
||||
export interface ReportDetailResponse {
|
||||
ok: boolean;
|
||||
report: Report;
|
||||
history: StatusHistoryItem[];
|
||||
media: Media[];
|
||||
ok: boolean;
|
||||
report: Report;
|
||||
history: StatusHistoryItem[];
|
||||
media: Media[];
|
||||
}
|
||||
|
||||
export interface InterventionSummaryResponse {
|
||||
ok: boolean;
|
||||
summary: Report;
|
||||
media: Media[];
|
||||
latest_messages: Message[];
|
||||
}
|
||||
|
||||
export interface AssignmentLinkResponse {
|
||||
ok: boolean;
|
||||
url: string;
|
||||
expires_at: string;
|
||||
ok: boolean;
|
||||
summary: Report;
|
||||
media: Media[];
|
||||
latest_messages: Message[];
|
||||
}
|
||||
|
||||
export interface MediaUploadResponse {
|
||||
ok: boolean;
|
||||
media: Media[];
|
||||
ok: boolean;
|
||||
media: Media[];
|
||||
}
|
||||
|
||||
export interface MessageListResponse {
|
||||
ok: boolean;
|
||||
messages: Message[];
|
||||
ok: boolean;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface FlagResponse {
|
||||
ok: boolean;
|
||||
flag_id: number;
|
||||
ok: boolean;
|
||||
flag_id: number;
|
||||
}
|
||||
|
||||
export interface RewardsResponse {
|
||||
ok: boolean;
|
||||
points: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
reward_events: RewardEvent[];
|
||||
ok: boolean;
|
||||
points: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
reward_events: RewardEvent[];
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
ok: boolean;
|
||||
page: number;
|
||||
per_page: number;
|
||||
notifications: Notification[];
|
||||
ok: boolean;
|
||||
page: number;
|
||||
per_page: number;
|
||||
notifications: Notification[];
|
||||
}
|
||||
|
||||
export interface AreaListResponse {
|
||||
ok: boolean;
|
||||
areas: Area[];
|
||||
ok: boolean;
|
||||
areas: Area[];
|
||||
}
|
||||
|
||||
export interface CategoryListResponse {
|
||||
ok: boolean;
|
||||
categories: Category[];
|
||||
ok: boolean;
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export interface TeamListResponse {
|
||||
ok: boolean;
|
||||
teams: Team[];
|
||||
ok: boolean;
|
||||
teams: Team[];
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
ok: boolean;
|
||||
users: AdminUser[];
|
||||
ok: boolean;
|
||||
users: AdminUser[];
|
||||
}
|
||||
|
||||
export interface ModerationListResponse {
|
||||
ok: boolean;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total?: number;
|
||||
flags: ModerationFlag[];
|
||||
ok: boolean;
|
||||
page: number;
|
||||
per_page: number;
|
||||
flags: ModerationFlag[];
|
||||
}
|
||||
|
||||
export interface OverviewStatsResponse {
|
||||
ok: boolean;
|
||||
reports_by_status: { status: ReportStatus; total: number }[];
|
||||
reports_by_area: { area: string; total: number }[];
|
||||
avg_resolution_hours: { value: number }[];
|
||||
reports_by_category: { category: string; total: number }[];
|
||||
reports_by_team: { team: string; total: number }[];
|
||||
monthly_reports: { month: string; total: number }[];
|
||||
avg_resolution_hours_by_category: { category: string; value: number }[];
|
||||
avg_resolution_hours_by_area: { area: string; value: number }[];
|
||||
total_users?: number;
|
||||
open_reports?: number;
|
||||
}
|
||||
ok: boolean;
|
||||
reports_by_status: { status: ReportStatus; total: number }[];
|
||||
reports_by_area: { area: string; total: number }[];
|
||||
avg_resolution_hours: { value: number }[];
|
||||
reports_by_category: { category: string; total: number }[];
|
||||
reports_by_team: { team: string; total: number }[];
|
||||
monthly_reports: { month: string; total: number }[];
|
||||
avg_resolution_hours_by_category: { category: string; value: number }[];
|
||||
avg_resolution_hours_by_area: { area: string; value: number }[];
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
declare module '#app' {
|
||||
interface PageMeta {
|
||||
auth?: {
|
||||
role?: 'CITIZEN' | 'OPERATOR' | 'ADMIN'
|
||||
}
|
||||
}
|
||||
}
|
||||
export {}
|
||||
@@ -1,15 +0,0 @@
|
||||
export const BARI_BOUNDS = {
|
||||
south: 41.0160584,
|
||||
north: 41.1697837,
|
||||
west: 16.7172376,
|
||||
east: 17.0334109,
|
||||
}
|
||||
|
||||
export const BARI_CENTER: [number, number] = [41.1257843, 16.8620293]
|
||||
|
||||
export const isWithinBariBounds = (latitude: number, longitude: number) =>
|
||||
latitude >= BARI_BOUNDS.south &&
|
||||
latitude <= BARI_BOUNDS.north &&
|
||||
longitude >= BARI_BOUNDS.west &&
|
||||
longitude <= BARI_BOUNDS.east
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { BARI_BOUNDS, isWithinBariBounds } from '~/utils/bari'
|
||||
|
||||
interface NominatimAddress {
|
||||
road?: string
|
||||
house_number?: string
|
||||
pedestrian?: string
|
||||
footway?: string
|
||||
path?: string
|
||||
cycleway?: string
|
||||
suburb?: string
|
||||
neighbourhood?: string
|
||||
city?: string
|
||||
town?: string
|
||||
village?: string
|
||||
municipality?: string
|
||||
}
|
||||
|
||||
interface NominatimResult {
|
||||
lat: string
|
||||
lon: string
|
||||
display_name: string
|
||||
address?: NominatimAddress
|
||||
}
|
||||
|
||||
export interface GeocodingSuggestion {
|
||||
label: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
const NOMINATIM_BASE_URL = 'https://nominatim.openstreetmap.org'
|
||||
const bariViewbox = `${BARI_BOUNDS.west},${BARI_BOUNDS.north},${BARI_BOUNDS.east},${BARI_BOUNDS.south}`
|
||||
|
||||
const compactAddress = (displayName: string, address?: NominatimAddress) => {
|
||||
if (!address) {
|
||||
return displayName
|
||||
}
|
||||
|
||||
const streetName = address.road || address.pedestrian || address.footway || address.path || address.cycleway
|
||||
const street = [streetName, address.house_number].filter(Boolean).join(' ')
|
||||
const locality = address.city || address.town || address.village || address.municipality || 'Bari'
|
||||
const area = address.suburb || address.neighbourhood
|
||||
const formatted = [street, locality, area]
|
||||
.filter(Boolean)
|
||||
.filter((part, index, all) => all.indexOf(part) === index)
|
||||
.join(', ')
|
||||
|
||||
return formatted || displayName
|
||||
}
|
||||
|
||||
const toSuggestion = (result: NominatimResult): GeocodingSuggestion | null => {
|
||||
const latitude = Number.parseFloat(result.lat)
|
||||
const longitude = Number.parseFloat(result.lon)
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
label: compactAddress(result.display_name, result.address),
|
||||
latitude,
|
||||
longitude
|
||||
}
|
||||
}
|
||||
|
||||
export const searchAddresses = async (query: string, limit = 5) => {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: 'jsonv2',
|
||||
addressdetails: '1',
|
||||
'accept-language': 'it',
|
||||
countrycodes: 'it',
|
||||
bounded: '1',
|
||||
viewbox: bariViewbox,
|
||||
limit: String(limit)
|
||||
})
|
||||
|
||||
const results = await $fetch<NominatimResult[]>(`${NOMINATIM_BASE_URL}/search?${params.toString()}`)
|
||||
|
||||
return results
|
||||
.map(toSuggestion)
|
||||
.filter((result): result is GeocodingSuggestion => Boolean(result))
|
||||
.filter((result) => isWithinBariBounds(result.latitude, result.longitude))
|
||||
}
|
||||
|
||||
export const reverseGeocode = async (latitude: number, longitude: number) => {
|
||||
const params = new URLSearchParams({
|
||||
lat: String(latitude),
|
||||
lon: String(longitude),
|
||||
zoom: '18',
|
||||
format: 'jsonv2',
|
||||
addressdetails: '1',
|
||||
'accept-language': 'it'
|
||||
})
|
||||
|
||||
const result = await $fetch<NominatimResult | null>(`${NOMINATIM_BASE_URL}/reverse?${params.toString()}`)
|
||||
|
||||
if (!result) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return compactAddress(result.display_name, result.address)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
services:
|
||||
visione-frontend:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
NUXT_SITE_URL: ${NUXT_PUBLIC_URL}
|
||||
NUXT_PUBLIC_URL: ${NUXT_PUBLIC_URL}
|
||||
NUXT_PUBLIC_API: ${NUXT_PUBLIC_API}
|
||||
environment:
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
NUXT_SITE_URL: ${NUXT_PUBLIC_URL}
|
||||
NUXT_PUBLIC_URL: ${NUXT_PUBLIC_URL}
|
||||
NUXT_PUBLIC_API: ${NUXT_PUBLIC_API}
|
||||
restart: always
|
||||
@@ -6,12 +6,9 @@ export default defineNuxtConfig({
|
||||
'@nuxt/image',
|
||||
'@nuxt/fonts',
|
||||
'@nuxtjs/sitemap',
|
||||
'@nuxtjs/robots',
|
||||
'@vite-pwa/nuxt'
|
||||
'@nuxtjs/robots'
|
||||
],
|
||||
|
||||
ssr: true,
|
||||
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
url: 'http://localhost:3000',
|
||||
@@ -20,14 +17,13 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
site: {
|
||||
url: 'https://visione.studentindipendenti.it',
|
||||
url: 'http://localhost:3000',
|
||||
name: 'Visione - Un\'occhio per la sicurezza del cittadino.',
|
||||
},
|
||||
|
||||
|
||||
css: [
|
||||
'leaflet/dist/leaflet.css',
|
||||
'bootstrap-italia/dist/css/bootstrap-italia.min.css',
|
||||
'@/assets/css/style.css'
|
||||
'@/assets/css/style.css',
|
||||
'bootstrap-italia/dist/css/bootstrap-italia.min.css'
|
||||
],
|
||||
|
||||
fonts: {
|
||||
@@ -36,51 +32,13 @@ export default defineNuxtConfig({
|
||||
{ name: 'Roboto Mono', provider: 'google' },
|
||||
{ name: 'Titillium Web', provider: 'google' }
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
app: {
|
||||
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: [
|
||||
{ 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}']
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -14,16 +14,12 @@
|
||||
"@nuxt/image": "^2.0.0",
|
||||
"@nuxtjs/robots": "6.0.9",
|
||||
"@nuxtjs/sitemap": "8.0.15",
|
||||
"@vite-pwa/nuxt": "1.1.1",
|
||||
"bootstrap-italia": "^2.18.1",
|
||||
"codice-fiscale-js": "^2.3.23",
|
||||
"leaflet": "^1.9.4",
|
||||
"nuxt": "^4.4.7",
|
||||
"vue": "^3.5.35",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/fonts": "^0.14.0",
|
||||
"@types/leaflet": "^1.9.21"
|
||||
"@nuxt/fonts": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 42 KiB |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 92 KiB |