6 Commits
11 changed files with 3444 additions and 310 deletions
+103 -53
View File
@@ -1,75 +1,125 @@
# Nuxt Minimal Starter
<div align="center">
<img src="https://git.reloia.uk/SnitchItaly/frontend/raw/branch/master/public/icon-512.webp" width="128" height="128" alt="Logo Visione">
<h1>Visione</h1>
<p><b>Un'occhio per la sicurezza del cittadino.</b></p>
</div>
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
**Visione** è una piattaforma progettata per gestire le segnalazioni dei cittadini, implementando un flusso operativo
sicuro, trasparente e istituzionalmente conforme.
## Setup
## Punti di Forza e Funzionalità Principali
Make sure to install dependencies:
- **Conformità Istituzionale e UX/UI Standardizzata:** Il frontend è sviluppato utilizzando **Bootstrap Italia** (creato
da Designers Italia). Questa scelta assicura un'identità visiva coerente con i servizi pubblici italiani, garantendo
uniformità, riconoscibilità e accessibilità.
- **Trasparenza e Open Data:** Il sistema espone un endpoint pubblico Open Data con dataset anonimizzati relativi alle
segnalazioni chiuse o risolte, favorendo la trasparenza amministrativa.
- **Automazione e Smistamento Intelligente:** Il calcolo automatico della "Zona" di competenza tramite le coordinate
geografiche elimina i colli di bottiglia durante il triage iniziale, instradando immediatamente la segnalazione
all'operatore corretto.
- **Sicurezza e Onboarding Smart:** L'autenticazione è gestita tramite **OTP (One-Time Password)** e integrazione con *
*CIE (Carta d'Identità Elettronica)**, riducendo l'uso di password statiche e garantendo identità verificate.
- **Infrastruttura DevOps e CI/CD:** Sfruttiamo un'istanza self-hosted di **Gitea** per il versionamento del codice e un
flusso di deployment automatizzato basato su webhook con **Dokploy**, assicurando un rilascio continuo e stabile.
- **Ottimizzazione SEO e Reperibilità:** Configurazione avanzata per sitemap e `robots.txt` (via `@nuxtjs/robots` e
`@nuxtjs/sitemap`) per esplicitare chiaramente le pagine pubbliche e separarle dalle aree private.
## Tecnologie Utilizzate
Il progetto è costruito su uno stack moderno orientato a performance, accessibilità e manutenibilità:
- **Framework Core:** [Nuxt 4](https://nuxt.com/) (Vue 3) con rendering SSR attivato.
- **UI / Styling:** [Bootstrap Italia](https://italia.github.io/bootstrap-italia/) per componenti accessibili e conformi
agli standard AGID.
- **Mappe e Georeferenziazione:** [Leaflet](https://leafletjs.com/) interfacciato con Nominatim/OpenStreetMap per il
suggerimento e la validazione degli indirizzi.
- **PWA e Ottimizzazione:** Modulo `@vite-pwa/nuxt` per Progressive Web App (manifest auto-generato) e moduli ufficiali
Nuxt per icone, font (Google Fonts: Lora, Roboto Mono, Titillium Web) ed elaborazione immagini.
- **Infrastruttura:** Docker, Docker Compose, NodeJS 22.
- **Package Manager:** **Yarn**.
## Limiti e Punti di Attenzione
L'attuale architettura presenta alcune criticità e aree di miglioramento individuate durante i test:
1. **Rischio di Abuso e Moderazione:** L'apertura della piattaforma all'invio di messaggi e allegati potrebbe generare
un elevato carico di moderazione. In futuro, andranno valutati filtri automatici o code dedicate.
2. **Dipendenza da API Cartografiche di Terze Parti:** L'utilizzo di Nominatim/OpenStreetMap per il geocoding esonera
dal lock-in di provider commerciali (es. Google Maps), ma introduce dipendenze su disponibilità e policy d'uso di
servizi esterni.
3. **Barriere all'Ingresso:** I flussi OTP e CIE, seppur estremamente sicuri, possono rappresentare un "attrito" (
digital divide) per le fasce di utenza più anziane o meno avvezze agli strumenti digitali.
---
## Come utilizzare il progetto (Sviluppo)
### Requisiti
- Node.js (v22 o superiore consigliato)
- **Yarn** (utilizza esclusivamente `yarn`, non `npm`)
### 1. Installazione Dipendenze
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
### 2. Configurazione Variabili d'Ambiente (.env)
Start the development server on `http://localhost:3000`:
Copia il file di esempio per creare la tua configurazione locale:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
cp .env.example .env
```
## Production
Il file `.env` richiede le seguenti chiavi:
Build the application for production:
- `NUXT_PUBLIC_URL`: URL pubblico dell'applicativo frontend (es. `http://localhost:3000`).
- `NUXT_PUBLIC_API`: URL base per il backend API (es. `http://localhost:5555`).
*(Nota: il proxy integrato in `nuxt.config.ts` inoltrerà già le richieste di `/api/v1` al target di staging
configurato `https://api.visionebari.it` in ambiente di sviluppo).*
### 3. Avvio del Server di Sviluppo
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
yarn run dev
```
Locally preview production build:
Il server sarà accessibile all'indirizzo [http://localhost:3000](http://localhost:3000).
---
## Distribuzione e Produzione (Docker)
Il progetto è ottimizzato per essere deployato tramite container Docker, sfruttando processi multi-stage per generare
un'immagine finale snella e performante basata su `node:22-bookworm`.
### Build locale e avvio tramite Docker Compose
Il file `docker-compose.yml` espone il servizio `visione-frontend`.
1. Assicurati che il file `.env` sia valorizzato correttamente.
2. Esegui il build e avvia in background:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
docker compose up -d --build
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
### Dettagli sul Dockerfile
- **Fase di Build (`AS build`):** Copia `package.json` e `yarn.lock`, esegue `yarn install`, copia i sorgenti ed esegue
`yarn run build`. Iniettando gli argomenti di environment per le API.
- **Fase di Run (`AS run`):** Esegue `yarn install --production` per alleggerire le dipendenze finali. Trasferisce
l'artefatto compilato `.output` e avvia il webserver sulla porta `80` tramite `node /app/server/index.mjs`.
## Testing e Azioni Migliorative Future
I test attuali coprono con successo i flussi primari (registrazione, autenticazione, gestione segnalazioni,
moderazione).
Prossimi step suggeriti:
- Consolidamento con test end-to-end (E2E) automatici per le rotte critiche (in particolare CIE/OTP).
- Verifiche di regressione per gli endpoint Open Data per garantirne l'integrità.
- Monitoraggio della qualità del dato geografico (segnalazioni in aree limite).
+1
View File
@@ -9,6 +9,7 @@ useSeoMeta({
});
</script>
<template>
<NuxtPwaManifest/>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
+2 -2
View File
@@ -70,8 +70,8 @@ export const useAuth = () => {
const res = await fetchCurrentUser()
if (res.ok) {
user.value = res.user
await refreshUnreadNotifications()
const path = currentPath ?? useNuxtApp().$router.currentRoute.value.path
await nuxtApp.runWithContext(() => refreshUnreadNotifications())
const path = currentPath ?? nuxtApp.$router.currentRoute.value.path
if (redirectOnSuccess && path.startsWith('/auth')) {
await nuxtApp.runWithContext(() => navigateTo('/dashboard'))
+15 -5
View File
@@ -1,11 +1,17 @@
import type {
CategoryListResponse,
CreateReportRequest,
CreateReportResponse, FeedbackRequest, FlagRequest, FlagResponse,
CreateReportResponse,
FeedbackRequest,
FlagRequest,
FlagResponse,
MediaUploadResponse,
MessageListResponse, MessageRequest, OkResponse,
MessageListResponse,
MessageRequest,
OkResponse,
ReportDetailResponse,
ReportListResponse
ReportListResponse,
TeamListResponse
} from "~/types/api";
export const useReports = () => {
@@ -20,7 +26,7 @@ export const useReports = () => {
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 })
@@ -39,6 +45,9 @@ 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,
@@ -50,5 +59,6 @@ export const useReports = () => {
addFeedback,
flagReport,
flagMessage,
getTeamSuggestions,
}
}
}
+4 -5
View File
@@ -36,7 +36,7 @@ const getStatusBadge = (status: string) => {
<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 nazionale per segnalare disservizi urbani. Scatta una foto, invia la segnalazione
Il portale ufficiale della città metropolitana di Bari per segnalare disservizi urbani. Scatta una foto, invia la segnalazione
e monitora l'intervento in totale trasparenza.
</p>
<div class="it-btn-container d-flex justify-content-center gap-3">
@@ -61,22 +61,21 @@ const getStatusBadge = (status: string) => {
<BIIcon color="primary" name="it-camera" size="xl"/>
</div>
<h3 class="h5 font-weight-semibold">1. Segnala</h3>
<p class="text-muted">Usa lo smartphone per fotografare il problema stradale e inviare la posizione
esatta.</p>
<p class="text-muted">Fotografa il problema stradale e condividi la tua posizione.</p>
</div>
<div class="col-12 col-md-4 mb-4">
<div class="mb-3">
<BIIcon color="primary" name="it-tool" size="xl"/>
</div>
<h3 class="h5 font-weight-semibold">2. Intervento</h3>
<p class="text-muted">Le squadre comunali prendono in carico la tua segnalazione e avviano i lavori.</p>
<p class="text-muted">Il Comune attiva subito le squadre operative per risolvere il problema.</p>
</div>
<div class="col-12 col-md-4 mb-4">
<div class="mb-3">
<BIIcon color="primary" name="it-star-outline" size="xl"/>
</div>
<h3 class="h5 font-weight-semibold">3. Punti e Feedback</h3>
<p class="text-muted">Problema risolto! Ottieni Punti Cittadino e lascia un feedback sull'intervento.</p>
<p class="text-muted">Problema risolto! Guadagna Punti Cittadino e valuta l'intervento.</p>
</div>
</div>
</div>
+79 -29
View File
@@ -16,7 +16,7 @@ if (!reportId) {
navigateTo('/operator')
}
const { getReportDetail, getMessages, addMessage, flagMessage, flagReport } = useReports()
const { getReportDetail, getMessages, addMessage, flagMessage, flagReport, getTeamSuggestions } = useReports()
const {
takeReport,
getInterventionSummary,
@@ -31,7 +31,9 @@ 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: teamsData } = await useAsyncData('operator-teams', () => getTeams())
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 || [])
@@ -39,7 +41,14 @@ 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 teams = computed(() => teamsData.value?.teams || [])
const allTeams = computed(() => (allTeamsData.value as any)?.teams || [])
const suggestedTeams = computed(() => (suggestedTeamsData.value as any)?.suggestions || [])
const otherTeams = computed(() => {
const suggestedIds = new Set(suggestedTeams.value.map((t: any) => Number(t.id)))
return allTeams.value.filter((t: any) => !suggestedIds.has(Number(t.id)))
})
const currentUserId = computed(() => user.value?.id ?? null)
const isTakenByCurrentOperator = computed(() => report.value?.assigned_operator_id === currentUserId.value)
@@ -47,7 +56,7 @@ const isOpenAndUnassigned = computed(() => report.value?.status === 'OPEN' && !r
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 && teams.value.length > 0)
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)
@@ -131,6 +140,26 @@ const reportFlagForm = reactive<FlagRequest>({
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] || []
@@ -550,8 +579,8 @@ const formattedInterventionLinkExpiry = computed(() =>
<strong>{{ summary.assigned_operator_id ? `#${summary.assigned_operator_id}` : 'Nessuno' }}</strong>
</li>
<li>
<span>Team disponibili</span>
<strong>{{ teams.length }}</strong>
<span>Team suggeriti</span>
<strong :class="{'text-primary': suggestedTeams.length > 0}">{{ suggestedTeams.length }}</strong>
</li>
<li>
<span>Allegati</span>
@@ -688,48 +717,69 @@ const formattedInterventionLinkExpiry = computed(() =>
</div>
</div>
<div v-if="isAssignModalOpen" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<div v-if="isAssignModalOpen" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
<!-- 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>
<div class="modal-body">
<form @submit.prevent="handleAssignTeam">
<div class="mb-3">
<!-- 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>
<option v-for="team in teams" :key="team.id" :value="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 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="teams.length === 0" class="form-text text-danger">
<small v-if="allTeams.length === 0" class="form-text text-danger mt-1">
Nessun team attivo disponibile.
</small>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<button type="button" class="btn btn-outline-secondary" @click="isAssignModalOpen = false">Annulla</button>
<button type="submit" class="btn btn-primary" :disabled="isAssigning || !selectedTeamId">
<span v-if="isAssigning" class="spinner-border spinner-border-sm me-2"></span>
Salva Assegnazione
</button>
</div>
</form>
</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>
<div v-if="isFlagReportModalOpen" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<div v-if="isFlagReportModalOpen" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
<!-- 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 segnalazione alla moderazione</h5>
<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">
@@ -738,7 +788,7 @@ const formattedInterventionLinkExpiry = computed(() =>
<label class="form-label">Motivo</label>
<input v-model="reportFlagForm.reason" type="text" class="form-control" maxlength="200" />
</div>
<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>
+44 -5
View File
@@ -6,11 +6,12 @@ export default defineNuxtConfig({
'@nuxt/image',
'@nuxt/fonts',
'@nuxtjs/sitemap',
'@nuxtjs/robots'
'@nuxtjs/robots',
'@vite-pwa/nuxt'
],
// TODO: riattivare prima di pubblicare il sito
ssr: false,
ssr: true,
runtimeConfig: {
public: {
url: 'http://localhost:3000',
@@ -22,7 +23,7 @@ export default defineNuxtConfig({
url: 'https://visione.studentindipendenti.it',
name: 'Visione - Un\'occhio per la sicurezza del cittadino.',
},
css: [
'leaflet/dist/leaflet.css',
'bootstrap-italia/dist/css/bootstrap-italia.min.css',
@@ -39,9 +40,47 @@ export default defineNuxtConfig({
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}']
}
}
})
+1
View File
@@ -14,6 +14,7 @@
"@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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

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