60 changed files with 8099 additions and 713 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
# Public URL of the website
NUXT_SITE_URL=http://localhost:3000
NUXT_PUBLIC_URL=http://localhost:3000
NUXT_PUBLIC_API=http://localhost:5555
+4
View File
@@ -22,3 +22,7 @@ logs
.env
.env.*
!.env.example
pnpm-*.yaml
.pnpm-store/
AGENTS.md
+28
View File
@@ -0,0 +1,28 @@
# 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"]
+376 -1
View File
@@ -1,3 +1,378 @@
: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;
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;
}
}
+17 -2
View File
@@ -1,9 +1,24 @@
<script setup lang="ts">
defineProps<{
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<{
name: string
size?: 'xs' | 'sm' | 'lg' | 'xl'
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'white'
}>()
const resolvedName = computed(() => iconAliases[props.name] || props.name)
</script>
<template>
@@ -15,6 +30,6 @@ defineProps<{
]"
aria-hidden="true"
>
<use :href="`/sprites.svg#${name}`"></use>
<use :href="`/sprites.svg#${resolvedName}`"></use>
</svg>
</template>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
confirmClass?: string
pending?: boolean
}>(), {
confirmLabel: 'Conferma',
cancelLabel: 'Annulla',
confirmClass: 'btn-danger',
pending: false,
})
const emit = defineEmits<{
cancel: []
confirm: []
}>()
const handleBackdropClick = (event: MouseEvent) => {
if (event.target === event.currentTarget && !props.pending) {
emit('cancel')
}
}
</script>
<template>
<div v-if="open">
<div class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<div
class="modal fade show d-block"
tabindex="-1"
style="z-index: 1050;"
@click="handleBackdropClick"
>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content shadow">
<div class="modal-header">
<h5 class="modal-title">{{ title }}</h5>
<button type="button" class="btn-close" :disabled="pending" @click="emit('cancel')"></button>
</div>
<div class="modal-body">
<p class="mb-0">{{ message }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" :disabled="pending" @click="emit('cancel')">
{{ cancelLabel }}
</button>
<button type="button" class="btn" :class="confirmClass" :disabled="pending" @click="emit('confirm')">
<span v-if="pending" class="spinner-border spinner-border-sm me-2"></span>
{{ confirmLabel }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
+5 -5
View File
@@ -14,8 +14,9 @@ const { sortedMembers } = useMembers();
<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" />
<a href="#" data-focus-mouse="false" class="d-flex align-items-center">
<img src="/icon.svg" alt="Logo Visione" style="width: 42px; height: 42px; margin-right: 12px;" />
<div class="it-brand-text">
<h2>Visione</h2>
<h3 class="d-none d-md-block">Segnalazioni anonime e sicure</h3>
@@ -47,10 +48,9 @@ const { sortedMembers } = useMembers();
<div class="it-footer-small-prints clearfix">
<div class="container">
<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="/note-legali">Note legali</a></li>
<li class="list-inline-item"><a href="/privacy">Privacy policy</a></li>
<li class="list-inline-item"><a href="/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>
+69 -25
View File
@@ -1,10 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import BIIcon from "~/components/BIIcon.vue";
const route = useRoute()
const isMobileMenuOpen = ref(false)
watch(() => route.path, () => {
@@ -13,12 +12,13 @@ watch(() => route.path, () => {
const navLinks = [
{ name: 'Home', path: '/' },
{ name: 'Fai una segnalazione', path: '/reports/create' },
{ name: 'Fai una segnalazione', path: '/reports/new' },
{ 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)
const { isAuthenticated, user, logout } = useAuth()
const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ? 'Pannello' : 'Dashboard')
</script>
<template>
@@ -28,33 +28,63 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
<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.interno.gov.it/it/ministero/dipartimenti/dipartimento-pubblica-sicurezza">Dipartimento della Pubblica Sicurezza</a>
<a class="d-none d-lg-block navbar-brand"
href="https://www.cittametropolitana.ba.it/">
Città metropolitana di Bari
</a>
<div class="nav-mobile">
<nav aria-label="Navigazione secondaria">
<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 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>
</nav>
</div>
<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
<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>
</NuxtLink>
<NuxtLink v-else class="btn btn-primary btn-sm" to="/dashboard">
Area Personale
<NuxtLink class="btn btn-primary btn-icon btn-full" to="/profile"
data-element="personal-area-dashboard" 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">{{ user?.first_name }}</span>
</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>
</div>
</div>
</div>
<!-- CENTER HEADER (Brand) -->
<div class="it-nav-wrapper">
<div class="it-header-center-wrapper">
<div class="container-xxl">
@@ -62,13 +92,13 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
<div class="col-12">
<div class="it-header-center-content-wrapper">
<div class="it-brand-wrapper">
<NuxtLink to="/">
<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>
</NuxtLink>
<NuxtLink to="/">
<img src="/icon.svg" alt="Logo Visione" style="width: 48px; height: 48px; margin-right: 16px;" />
<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>
</NuxtLink>
</div>
</div>
</div>
@@ -87,7 +117,7 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
aria-label="Mostra la navigazione"
@click="isMobileMenuOpen = true"
>
<BIIcon name="it-burger" />
<BIIcon name="it-burger"/>
</button>
<div
@@ -106,7 +136,7 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
<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" />
<BIIcon name="it-close-big"/>
</button>
</div>
@@ -141,11 +171,25 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
</template>
<style scoped>
:deep(.it-header-slim-wrapper) {
background-color: #036;
}
:deep(.it-nav-wrapper > div) {
background-color: rgb(0, 76.5, 153);
}
: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);
}
.overlay {
transition: opacity 0.3s ease;
opacity: 0;
pointer-events: none;
}
.overlay.show {
opacity: 1;
pointer-events: auto;
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
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="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer position-relative"
:class="{ 'border-primary bg-primary bg-opacity-10': isDragging, 'bg-light': !isDragging }"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="isDragging = false; handleFilesSelected(($event as DragEvent).dataTransfer?.files || null)"
@click="!disabled && openFileDialog()"
>
<BIIcon name="it-upload" size="xl" color="primary" class="mb-3" />
<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"
type="file"
:multiple="maxFiles > 1"
:accept="acceptedTypes"
:disabled="disabled"
class="d-none"
@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/')" :src="getFileUrl(file)" class="object-fit-cover" :alt="file.name" />
<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 name="it-file" size="xl" color="secondary" />
</div>
</div>
<button
type="button"
class="btn btn-danger btn-sm position-absolute top-0 end-0 m-1 rounded-circle p-1 d-flex align-items-center justify-content-center shadow"
style="width: 28px; height: 28px; z-index: 10;"
@click.stop="removeFile(idx)"
:disabled="disabled"
title="Rimuovi file"
>
<BIIcon name="it-close" size="sm" color="white" />
</button>
<div class="card-body p-2 d-flex flex-column justify-content-between">
<div class="small text-truncate fw-semibold" :title="file.name">{{ file.name }}</div>
<div 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>
+179
View File
@@ -0,0 +1,179 @@
<script setup lang="ts">
interface Emits {
(e: 'send', message: string, files: File[]): void
}
const emit = defineEmits<Emits>()
const message = ref('')
const isSubmitting = ref(false)
const files = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement>()
const handleSubmit = () => {
if (message.value.trim() === '' && files.value.length === 0) return
emit('send', message.value, [...files.value])
message.value = ''
files.value = []
}
const handleKeydown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
}
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files) {
const newFiles = Array.from(target.files)
files.value = [...files.value, ...newFiles].slice(0, 5) // max 5
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const removeFile = (idx: number) => {
files.value.splice(idx, 1)
}
</script>
<template>
<div class="message-composer mt-3">
<form @submit.prevent="handleSubmit">
<div v-if="files.length > 0" class="message-files mb-3">
<div v-for="(file, idx) in files" :key="idx" class="message-file-chip">
<BIIcon name="it-clip" size="xs" />
<span class="text-truncate file-name">{{ file.name }}</span>
<button type="button" class="btn-close btn-close-sm" @click="removeFile(idx)" aria-label="Rimuovi allegato"></button>
</div>
</div>
<div class="composer-shell">
<input
ref="fileInputRef"
type="file"
multiple
class="d-none"
@change="handleFileSelect"
accept="image/*,video/*,.pdf,.doc,.docx"
/>
<button
type="button"
class="btn btn-outline-primary btn-icon composer-attach"
title="Allega file"
@click="fileInputRef?.click()"
:disabled="isSubmitting || files.length >= 5"
>
<BIIcon name="it-clip" size="sm" />
</button>
<textarea
v-model="message"
class="form-control composer-textarea"
placeholder="Scrivi aggiornamento o domanda"
rows="2"
:disabled="isSubmitting"
@keydown="handleKeydown"
/>
<button
type="submit"
class="btn btn-primary composer-submit"
:disabled="isSubmitting || (message.trim() === '' && files.length === 0)"
>
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<BIIcon v-else name="it-arrow-right" size="sm" color="white" class="me-1" />
Invia
</button>
</div>
<small class="text-muted d-block mt-2">
`Ctrl` + `Invio` per inviare. Fino a 5 allegati.
</small>
</form>
</div>
</template>
<style scoped>
.message-composer {
border-top: 1px solid #d9e3ee;
padding-top: 1rem;
}
.message-files {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.message-file-chip {
align-items: center;
background: #fff;
border: 1px solid #d9e3ee;
border-radius: 4px;
display: inline-flex;
gap: 0.5rem;
max-width: 100%;
padding: 0.4rem 0.75rem;
}
.file-name {
max-width: 14rem;
}
.composer-shell {
align-items: flex-end;
background: #fff;
border: 1px solid #c5d0db;
border-radius: 4px;
display: grid;
gap: 0.75rem;
grid-template-columns: auto minmax(0, 1fr) auto;
padding: 0.75rem;
}
.composer-shell:focus-within {
border-color: #06c;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
}
.composer-attach {
flex-shrink: 0;
}
.composer-textarea {
border: 0;
box-shadow: none;
min-height: 4rem;
padding: 0;
resize: vertical;
}
.composer-submit {
min-width: 7rem;
}
.btn-close-sm {
font-size: 0.55rem;
}
@media (max-width: 767.98px) {
.composer-shell {
grid-template-columns: auto 1fr;
}
.composer-textarea {
grid-column: 1 / -1;
}
.composer-submit {
grid-column: 2;
justify-self: end;
}
}
</style>
+216
View File
@@ -0,0 +1,216 @@
<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 name="it-comment" size="lg" color="primary" class="mb-2" />
<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="message-row"
:class="{ 'is-current-user': isCurrentUser(msg.user_id) }"
>
<div v-if="!isCurrentUser(msg.user_id)" class="message-avatar" aria-hidden="true">
{{ senderInitials(msg) }}
</div>
<div
class="message-bubble-content"
:class="{
'message-bubble-outgoing': isCurrentUser(msg.user_id),
'message-bubble-incoming': !isCurrentUser(msg.user_id)
}"
>
<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
type="button"
class="btn btn-link btn-xs px-0"
:disabled="isFlaggingMessageId === msg.id"
@click="emit('flag', msg)"
>
<span v-if="isFlaggingMessageId === msg.id" class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
<BIIcon v-else name="it-flag" size="xs" class="me-1" />
Segnala messaggio
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.message-list {
border: 1px solid #d9e3ee;
border-radius: 4px;
max-height: 34rem;
overflow-y: auto;
padding: 1rem;
}
.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;
min-height: 14rem;
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>
+115
View File
@@ -0,0 +1,115 @@
<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">
<!-- Pagina Precedente -->
<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">&laquo;</span>
</button>
</li>
<!-- Numeri Pagine -->
<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>
<!-- Pagina Successiva -->
<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">&raquo;</span>
</button>
</li>
</ul>
</nav>
<!-- Info Pagina -->
<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>
+687
View File
@@ -0,0 +1,687 @@
<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)
// Map variables
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')
// Fix per le icone di Leaflet in Nuxt/Vite
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: '&copy; 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)
})
// Se c'è già una posizione, imposta il marker
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)
})
// Sincronizza input manuali con la mappa
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; /* Fix for Bootstrap elements overlapping map */
}
.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>
+90
View File
@@ -0,0 +1,90 @@
<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
}
const props = withDefaults(defineProps<Props>(), {
detailBasePath: '/reports',
actionVariant: 'open',
actionLabel: 'Open report',
showAssignee: false,
})
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" :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"
@click="emit('take', report.id)"
>
Prendi in carico
</button>
<NuxtLink
v-else
:to="reportHref(report)"
class="btn btn-outline-primary btn-xs btn-icon"
:aria-label="actionLabel"
:title="actionLabel"
>
<BIIcon name="it-arrow-right" size="xs" />
</NuxtLink>
</td>
</tr>
</tbody>
</table>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
<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: 'Chiusa', 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>
@@ -0,0 +1,68 @@
<script setup lang="ts">
const { getOverviewStats } = useAdmin()
const { data: statsRes, pending } = useAsyncData('admin-stats', () => getOverviewStats())
</script>
<template>
<div>
<div class="callout callout-highlight callout-danger mb-4">
<div class="callout-title">Pannello Admin</div>
<p>Accesso di livello 1. Da qui puoi gestire le configurazioni di sistema, le categorie e moderare le segnalazioni.</p>
</div>
<!-- Statistiche Globali (Mock o Reali) -->
<div class="row mb-5" v-if="!pending && statsRes?.ok">
<div class="col-6 col-md-3 mb-3">
<div class="card-wrapper card-space">
<div class="card card-bg text-center">
<div class="card-body">
<h2 class="text-primary">{{ statsRes.total_users || 0 }}</h2>
<p class="card-text">Utenti Registrati</p>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3 mb-3">
<div class="card-wrapper card-space">
<div class="card card-bg text-center">
<div class="card-body">
<h2 class="text-warning">
{{ statsRes.open_reports ?? statsRes.reports_by_status?.find(s => s.status === 'OPEN')?.total ?? 0 }}
</h2>
<p class="card-text">Segn. Aperte</p>
</div>
</div>
</div>
</div>
</div>
<h3 class="mb-3">Gestione Sistema</h3>
<div class="row">
<div class="col-12 col-md-4 mb-3">
<NuxtLink to="/admin/catalog" class="btn btn-outline-primary w-100 py-3 d-flex flex-column align-items-center">
<BIIcon name="it-list" size="sm" color="primary" class="mb-2" />
Categorie e Team
</NuxtLink>
</div>
<div class="col-12 col-md-4 mb-3">
<NuxtLink to="/admin/areas" class="btn btn-outline-primary w-100 py-3 d-flex flex-column align-items-center">
<BIIcon name="it-map-marker" size="sm" color="primary" class="mb-2" />
Aree
</NuxtLink>
</div>
<div class="col-12 col-md-4 mb-3">
<NuxtLink to="/admin/users" class="btn btn-outline-primary w-100 py-3 d-flex flex-column align-items-center">
<BIIcon name="it-user" size="sm" color="primary" class="mb-2" />
Gestione Utenti
</NuxtLink>
</div>
<div class="col-12 col-md-6 mb-3">
<NuxtLink to="/admin/moderation" class="btn btn-outline-danger w-100 py-3 d-flex flex-column align-items-center">
<BIIcon name="it-warning" size="sm" color="danger" class="mb-2" />
Moderazione Abusi
</NuxtLink>
</div>
</div>
</div>
</template>
@@ -0,0 +1,93 @@
<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>
@@ -0,0 +1,58 @@
<script setup lang="ts">
const { getOpenReports, getAssignedReports, takeReport } = useOperator()
const { data: openReportsRes, refresh: refreshOpen } = useAsyncData('open-reports', () => getOpenReports({ per_page: 5 }))
const { data: assignedReportsRes, refresh: refreshAssigned } = useAsyncData('assigned-reports', () => getAssignedReports({ per_page: 5 }))
const handleTakeReport = async (id: number) => {
const res = await takeReport(id)
if (res.ok) {
await Promise.all([refreshOpen(), refreshAssigned()])
}
}
</script>
<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">Da prendere in carico</h3>
<NuxtLink to="/operator" class="btn btn-link btn-sm pe-0">Apri coda</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 || []"
action-variant="manage"
detail-base-path="/operator/reports"
@take="handleTakeReport"
/>
</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>
+26 -1
View File
@@ -3,6 +3,7 @@ import type {
ModerationListResponse,
OkResponse,
OverviewStatsResponse, TeamListResponse, UpdateModerationRequest,
UpdateAreaRequest, UpdateCategoryRequest, UpdateTeamRequest,
UpdateUserBanRequest, UpdateUserRolesRequest,
UserListResponse
} from "~/types/api";
@@ -21,23 +22,41 @@ export const useAdmin = () => {
useApiFetch<OkResponse>(`/api/v1/admin/users/${id}/roles`, { method: 'PATCH', body })
const getAreas = () =>
useApiFetch<AreaListResponse>('/api/v1/admin/areas', { method: 'GET' })
useApiFetch<AreaListResponse>('/api/v1/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 })
@@ -51,10 +70,16 @@ export const useAdmin = () => {
updateUserRoles,
getAreas,
createArea,
updateArea,
deleteArea,
getCategories,
createCategory,
updateCategory,
deleteCategory,
getTeams,
createTeam,
updateTeam,
deleteTeam,
getModerationFlags,
updateModerationFlag,
}
+23 -3
View File
@@ -2,14 +2,34 @@ import { defu } from 'defu'
export const useApiFetch = <T>(request: string, options: Parameters<typeof $fetch>[1] = {}) => {
const config = useRuntimeConfig()
const token = useCookie('auth_token') // Make sure this matches the cookie name you use on login
const nuxtApp = useNuxtApp()
const tokenCookie = useCookie('auth_token', { path: '/' })
const tokenState = useState<string | null>('auth_token_state')
const tokenValue = tokenState.value ?? tokenCookie.value
const defaults: Parameters<typeof $fetch>[1] = {
baseURL: config.public.api as string,
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {},
headers: tokenValue ? { Authorization: `Bearer ${tokenValue}` } : {},
}
const params = defu(options, defaults)
return $fetch<T>(request, params)
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
})
}
+82 -1
View File
@@ -4,10 +4,78 @@ import type {
OkResponse,
OtpRequestResponse, PasswordLoginRequest, RegisterCitizenRequest,
RegisterCitizenResponse, RewardsResponse,
TokenResponse, VerifyOtpRequest
TokenResponse, VerifyOtpRequest, UpdateProfileRequest
} 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 isAuthenticated = computed(() => !!(tokenState.value || token.value))
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 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
const path = currentPath ?? useNuxtApp().$router.currentRoute.value.path
if (res.user.must_change_password && path !== '/auth/change-password') {
await nuxtApp.runWithContext(() => navigateTo('/auth/change-password'))
} else 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({ redirectOnSuccess: true })
}
const logout = () => {
token.value = null
tokenState.value = null
user.value = null
nuxtApp.runWithContext(() => navigateTo('/auth/login'))
}
const register = (body: RegisterCitizenRequest) =>
useApiFetch<RegisterCitizenResponse>('/api/v1/auth/register', { method: 'POST', body })
@@ -23,6 +91,9 @@ export const useAuth = () => {
const changePassword = (body: ChangePasswordRequest) =>
useApiFetch<OkResponse>('/api/v1/auth/change-password', { method: 'POST', body })
const updateProfile = (body: UpdateProfileRequest) =>
useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'PATCH', body })
const fetchCurrentUser = () =>
useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'GET' })
@@ -36,11 +107,21 @@ export const useAuth = () => {
useApiFetch<OkResponse>(`/api/v1/notifications/${id}/read`, { method: 'PATCH' })
return {
user,
token,
tokenState,
isSyncingUser,
isAuthenticated,
handleLoginSuccess,
syncUser,
logout,
register,
requestOtp,
verifyOtp,
loginWithPassword,
changePassword,
updateProfile,
fetchCurrentUser,
fetchRewards,
fetchNotifications,
+14
View File
@@ -1,8 +1,10 @@
import type {
AssignmentLinkResponse,
AssignTeamRequest,
InterventionSummaryResponse,
OkResponse,
PagedReportListResponse,
TeamListResponse,
UpdateStatusRequest
} from "~/types/api";
@@ -16,6 +18,9 @@ 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' })
@@ -28,13 +33,22 @@ 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' })
return {
getOpenReports,
getAssignedReports,
getOtherOperatorReports,
getTeams,
takeReport,
updateReportStatus,
assignTeam,
getInterventionSummary,
createInterventionLink,
revokeInterventionLink,
}
}
+5
View File
@@ -1,4 +1,5 @@
import type {
CategoryListResponse,
CreateReportRequest,
CreateReportResponse, FeedbackRequest, FlagRequest, FlagResponse,
MediaUploadResponse,
@@ -11,6 +12,9 @@ export const useReports = () => {
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 })
@@ -37,6 +41,7 @@ export const useReports = () => {
return {
getReports,
getCategories,
createReport,
getReportDetail,
uploadMedia,
+7
View File
@@ -11,6 +11,7 @@ import Footer from "~/components/Footer.vue";
<slot />
</main>
<div class="italian-flag"></div>
<Footer />
</div>
</template>
@@ -22,6 +23,12 @@ 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%);
}
.app-main {
flex-grow: 1;
}
+39
View File
@@ -0,0 +1,39 @@
export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuth()
// Se c'è un token ma l'utente non è ancora caricato e non stiamo già sincronizzando, sincronizza
if ((auth.token.value || auth.tokenState.value) && !auth.user.value && !auth.isSyncingUser.value) {
try {
await auth.syncUser({ currentPath: to.path })
} catch (error) {
console.error('[Auth Middleware] Sync failed:', error)
return navigateTo('/auth/login')
}
}
// Se non autenticato, redirige al login
if (!auth.isAuthenticated.value) {
return navigateTo('/auth/login')
}
// Verifica ruoli se richiesto
const requiredRole = to.meta.auth?.role
if (requiredRole && auth.user.value) {
const userRole = auth.user.value.role.toUpperCase()
const roleLevels: Record<string, number> = {
'CITIZEN': 1,
'OPERATOR': 2,
'ADMIN': 3
}
const userLevel = roleLevels[userRole] || 0
const requiredLevel = roleLevels[requiredRole] || 0
if (userLevel < requiredLevel) {
console.warn(`[Auth] User role ${userRole} (level ${userLevel}) insufficient for required ${requiredRole} (level ${requiredLevel})`)
return navigateTo('/')
}
}
})
+15
View File
@@ -0,0 +1,15 @@
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')
}
})
+417
View File
@@ -0,0 +1,417 @@
<script setup lang="ts">
import { ref, reactive, nextTick, onBeforeUnmount, computed } 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 deletingAreaId = ref<number | null>(null)
const areaPendingDelete = ref<any | null>(null)
// Map variables
let map: 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 openCreateModal = () => {
actionError.value = ''
editingAreaId.value = null
formData.name = ''
formData.description = ''
formData.geojson = ''
jsonError.value = ''
mapPoints.value = []
isCreating.value = true
nextTick(async () => {
// Import leaflet solo client-side
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)
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 = ''
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 = ''
editingAreaId.value = area.id
formData.name = area.name || ''
formData.description = area.description || ''
formData.geojson = area.boundary_geojson ? JSON.stringify(area.boundary_geojson, null, 2) : ''
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)
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 = ''
mapPoints.value.push([e.latlng.lat, e.latlng.lng])
polygon.setLatLngs(mapPoints.value)
updateGeojson()
})
})
}
const clearMap = () => {
mapPoints.value = []
if (polygon) {
polygon.setLatLngs([])
}
formData.geojson = ''
}
const updateGeojson = () => {
if (mapPoints.value.length < 3) {
formData.geojson = ''
return
}
const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number]) // Lng, Lat
if (coords[0]) {
coords.push([...coords[0]]) // Chiudi poligono
}
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()
})
const handleCreate = async () => {
jsonError.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) {
console.error('Failed to save area', err)
actionError.value = '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 name="it-plus" size="xs" class="me-1" />
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>
<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>
<!-- Modale Creazione -->
<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">
<form @submit.prevent="handleCreate">
<div class="row">
<div class="col-md-5">
<div class="mb-3">
<label class="form-label">Nome Area</label>
<input type="text" class="form-control" v-model="formData.name" required placeholder="Es. Centro Storico" />
</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 type="button" class="btn btn-primary" :disabled="!formData.name || !formData.geojson" @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 {
background: #e9ecef;
}
.modal-dialog {
margin-block: 0!important;
}
</style>
+446
View File
@@ -0,0 +1,446 @@
<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, getTeams, createTeam, updateTeam, deleteTeam, getAreas } = useAdmin()
const { getCategories } = useReports()
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" type="text" class="form-control" required />
</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"
type="button"
class="btn btn-outline-secondary ms-2"
@click="resetCategoryForm"
>
Annulla
</button>
</form>
<div class="table-responsive report-table-wrapper">
<table class="table table-hover report-table mb-0">
<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">
<button class="btn btn-sm btn-outline-primary me-2" @click="editCategory(category)">
Modifica
</button>
<button
class="btn btn-sm"
:class="category.active === false ? 'btn-outline-success' : 'btn-outline-secondary'"
@click="toggleCategoryActive(category)"
>
{{ category.active === false ? 'Attiva' : 'Disattiva' }}
</button>
<button
class="btn btn-sm btn-outline-danger ms-2"
:disabled="deletingCategoryId === category.id"
@click="askDeleteCategory(category)"
>
<span v-if="deletingCategoryId === category.id" class="spinner-border spinner-border-sm me-1"></span>
Elimina
</button>
</td>
</tr>
<tr v-if="categories.length === 0">
<td colspan="4" class="text-muted">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" type="text" class="form-control" required />
</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" type="text" class="form-control" />
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary" :disabled="isSavingTeam || !teamForm.name.trim() || !teamForm.team_category_id">
<span v-if="isSavingTeam" class="spinner-border spinner-border-sm me-2"></span>
{{ editingTeamId ? 'Salva team' : 'Crea team' }}
</button>
<button
v-if="editingTeamId"
type="button"
class="btn btn-outline-secondary ms-2"
@click="resetTeamForm"
>
Annulla
</button>
</div>
</form>
<div class="table-responsive report-table-wrapper">
<table class="table table-hover report-table mb-0">
<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">
<button class="btn btn-sm btn-outline-primary me-2" @click="editTeam(team)">
Modifica
</button>
<button
class="btn btn-sm"
:class="team.active === false ? 'btn-outline-success' : 'btn-outline-secondary'"
@click="toggleTeamActive(team)"
>
{{ team.active === false ? 'Attiva' : 'Disattiva' }}
</button>
<button
class="btn btn-sm btn-outline-danger ms-2"
:disabled="deletingTeamId === team.id"
@click="askDeleteTeam(team)"
>
<span v-if="deletingTeamId === team.id" class="spinner-border spinner-border-sm me-1"></span>
Elimina
</button>
</td>
</tr>
<tr v-if="teams.length === 0">
<td colspan="6" class="text-muted">Nessun team configurato.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<ConfirmModal
:open="categoryPendingDelete !== null"
title="Elimina categoria"
:message="categoryDeleteMessage"
confirm-label="Elimina categoria"
confirm-class="btn-danger"
:pending="deletingCategoryId !== null"
@cancel="closeDeleteCategoryModal"
@confirm="handleDeleteCategory"
/>
<ConfirmModal
:open="teamPendingDelete !== null"
title="Elimina team"
:message="teamDeleteMessage"
confirm-label="Elimina team"
confirm-class="btn-danger"
:pending="deletingTeamId !== null"
@cancel="closeDeleteTeamModal"
@confirm="handleDeleteTeam"
/>
</div>
</template>
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { reactive, ref, watch } 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 {
const res = await updateModerationFlag(resolveModal.flagId, {
status: 'RESOLVED',
reject_report: resolveModal.rejectReport,
resolution_note: resolveModal.note || undefined
})
if (res.ok) {
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 {
const res = await updateModerationFlag(dismissModal.flagId, { status: 'DISMISSED' })
if (res.ok) {
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'
})
}
</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="pending" class="text-center py-5">
<div class="spinner-border text-danger" role="status"></div>
</div>
<div v-else-if="!flagsRes?.flags || flagsRes.flags.length === 0" class="alert alert-success">
Nessun elemento da moderare al momento.
</div>
<div v-if="actionError" class="alert alert-danger">
{{ actionError }}
</div>
<div v-else>
<div class="card mb-3 shadow-sm border-0" v-for="flag in flagsRes.flags" :key="flag.id">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<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'
}">{{ 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-3">
<span class="badge bg-info me-2">{{ 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-3 small">
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, true)">
<BIIcon name="it-ban" size="xs" /> Risolvi & Rigetta Segn.
</button>
<button class="btn btn-outline-warning btn-sm" @click="openResolveModal(flag.id, false)">
<BIIcon name="it-check" size="xs" /> Risolvi (Solo Avviso)
</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>
+228
View File
@@ -0,0 +1,228 @@
<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((r) => r.trim().toUpperCase()).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
}
}
// Modal per i ruoli
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>
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
useHead({ title: 'Cambio Password - Visione' })
const { changePassword, user, syncUser } = useAuth()
const router = useRouter()
const formData = reactive({
current_password: '',
new_password: '',
confirm_password: ''
})
const isLoading = ref(false)
const errorMessage = ref('')
const showCurrent = ref(false)
const showNew = ref(false)
const showConfirm = ref(false)
const canSubmit = computed(() => {
return (
formData.current_password.length > 0 &&
formData.new_password.length >= 8 &&
formData.new_password === formData.confirm_password
)
})
const handleSubmit = async () => {
if (!canSubmit.value) return
isLoading.value = true
errorMessage.value = ''
try {
const res = await changePassword({
current_password: formData.current_password,
new_password: formData.new_password
})
if (res.ok) {
await syncUser()
await router.push('/profile')
}
} catch (error: any) {
errorMessage.value = error.data?.message || 'Errore. Verifica la password attuale.'
} finally {
isLoading.value = false
}
}
</script>
<template>
<div class="py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-12 col-lg-8 col-xl-6">
<div v-if="user?.must_change_password" class="callout callout-highlight callout-warning mb-4">
<div class="callout-title d-flex align-items-center">
<BIIcon name="it-warning-circle" size="sm" color="warning" class="me-2" />
Azione richiesta
</div>
<p class="mb-0">Per motivi di sicurezza, imposta una nuova password per il tuo account.</p>
</div>
<div class="card card-bg border-0 shadow-sm">
<div class="card-body p-4 p-lg-5">
<h1 class="h3 mb-3">Cambia password</h1>
<p class="text-muted mb-4">
Aggiorna credenziali prima di continuare. Password nuova deve essere diversa da quella attuale.
</p>
<form @submit.prevent="handleSubmit" class="d-grid gap-4">
<div class="password-field">
<label for="current_password" class="form-label">Password attuale</label>
<div class="password-field-control">
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
<input
id="current_password"
v-model="formData.current_password"
:type="showCurrent ? 'text' : 'password'"
class="form-control"
autocomplete="current-password"
>
<button
type="button"
class="btn btn-password-toggle"
:aria-label="showCurrent ? 'Nascondi password attuale' : 'Mostra password attuale'"
@click="showCurrent = !showCurrent"
>
<BIIcon :name="showCurrent ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button>
</div>
</div>
<div class="password-field">
<label for="new_password" class="form-label">Nuova password</label>
<div class="password-field-control">
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
<input
id="new_password"
v-model="formData.new_password"
:type="showNew ? 'text' : 'password'"
class="form-control"
autocomplete="new-password"
>
<button
type="button"
class="btn btn-password-toggle"
:aria-label="showNew ? 'Nascondi nuova password' : 'Mostra nuova password'"
@click="showNew = !showNew"
>
<BIIcon :name="showNew ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button>
</div>
<div class="form-text">Minimo 8 caratteri.</div>
</div>
<div class="password-field">
<label for="confirm_password" class="form-label">Conferma nuova password</label>
<div class="password-field-control">
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
<input
id="confirm_password"
v-model="formData.confirm_password"
:type="showConfirm ? 'text' : 'password'"
:class="{ 'is-invalid': formData.confirm_password && formData.confirm_password !== formData.new_password }"
class="form-control"
autocomplete="new-password"
>
<button
type="button"
class="btn btn-password-toggle"
:aria-label="showConfirm ? 'Nascondi conferma password' : 'Mostra conferma password'"
@click="showConfirm = !showConfirm"
>
<BIIcon :name="showConfirm ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button>
</div>
</div>
<div v-if="errorMessage" class="alert alert-danger mb-0" role="alert">
{{ errorMessage }}
</div>
<button
type="submit"
class="btn btn-primary w-100 py-2"
:disabled="!canSubmit || isLoading"
>
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
{{ isLoading ? 'Aggiornamento in corso' : 'Aggiorna password' }}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.password-field {
display: grid;
gap: 0.5rem;
}
.password-field-control {
align-items: center;
background: #fff;
border: 1px solid #c5d0db;
border-radius: 8px;
display: grid;
gap: 0.5rem;
grid-template-columns: auto minmax(0, 1fr) auto;
padding-left: 0.875rem;
}
.password-field-control:focus-within {
border-color: #06c;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.16);
}
.password-field-icon {
color: #5c6f82;
}
.password-field-control .form-control {
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
min-width: 0;
padding-left: 0;
padding-right: 0;
}
.btn-password-toggle {
align-items: center;
color: #06c;
display: inline-flex;
height: 100%;
justify-content: center;
min-height: 3rem;
padding: 0 0.875rem;
}
.btn-password-toggle:hover,
.btn-password-toggle:focus {
background: rgba(0, 102, 204, 0.08);
color: #06c;
}
</style>
+185
View File
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref } from 'vue'
definePageMeta({
middleware: 'guest'
})
useHead({ title: 'Accedi - Visione' })
const { requestOtp, verifyOtp, loginWithPassword, handleLoginSuccess } = useAuth()
const router = useRouter()
// UI State
const loginMethod = ref<'otp' | 'password'>('otp')
const otpStep = ref<1 | 2>(1)
const isLoading = ref(false)
const errorMessage = ref('')
const email = ref('')
const password = ref('')
const otpCode = ref('')
const showPassword = ref(false)
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 handlePasswordLogin = async () => {
isLoading.value = true
errorMessage.value = ''
try {
const res = await loginWithPassword({ email: email.value, password: password.value })
await handleLoginSuccess(res.token)
} catch (err: any) {
errorMessage.value = 'Credenziali non valide.'
} 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-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" disabled title="Integrazione CIE prevista per la fase successiva">
<BIIcon name="it-card" size="sm" color="white" />
Entra con CIE (Presto disponibile)
</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="errorMessage" class="alert alert-danger mb-4" role="alert">
{{ errorMessage }}
</div>
<div v-if="loginMethod === 'otp'">
<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>
Ricevi codice OTP
</button>
</form>
<form v-else @submit.prevent="handleVerifyOtp">
<div class="alert alert-success mb-4 small">
Abbiamo inviato un codice OTP a <strong>{{ email }}</strong>.
</div>
<div class="form-group mb-4">
<input type="text" class="form-control text-center text-tracking-widest fs-4" id="otp" v-model="otpCode" required maxlength="6" autocomplete="one-time-code">
<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>
<form v-else @submit.prevent="handlePasswordLogin">
<div class="form-group mb-4">
<input type="email" class="form-control" id="emailPwd" v-model="email" required>
<label for="emailPwd" :class="{ 'active': email.length > 0 }">Indirizzo Email</label>
</div>
<div class="form-group mb-4 position-relative">
<input :type="showPassword ? 'text' : 'password'" class="form-control pe-5" id="password" v-model="password" required>
<label for="password" :class="{ 'active': password.length > 0 }">Password</label>
<button
type="button"
class="btn bg-transparent border-0 shadow-none position-absolute p-2"
style="right: 0; top: 2px; z-index: 10;"
@click="showPassword = !showPassword"
:aria-pressed="showPassword"
aria-label="Mostra/Nascondi password"
>
<BIIcon :name="showPassword ? 'it-password-visible' : 'it-password-invisible'" color="primary" size="sm" />
</button>
</div>
<button type="submit" class="btn btn-primary w-100 mb-3" :disabled="isLoading">
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2" role="status"></span>
Accedi
</button>
</form>
<div class="text-center mt-4">
<a href="#" class="small text-decoration-none" @click.prevent="loginMethod = loginMethod === 'otp' ? 'password' : 'otp'; errorMessage = ''">
{{ loginMethod === 'otp' ? 'Vuoi usare la password? Clicca qui' : 'Usa il login sicuro senza password (OTP)' }}
</a>
</div>
</div>
</div>
<div 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;
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<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 showPassword = ref(false)
const form = ref({
first_name: '',
last_name: '',
fiscal_code: '',
email: '',
password: '',
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,
password: form.value.password,
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 al login...
</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>
<!-- FIX LAYOUT: form-group separato da col -->
<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">Credenziali e Contatti</h2>
<div class="row mb-4">
<div class="col-12 col-md-6 mb-4 mb-md-0">
<div class="form-group">
<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 class="col-12 col-md-6">
<!-- INPUT PASSWORD FIXATO -->
<div class="form-group position-relative">
<input :type="showPassword ? 'text' : 'password'" class="form-control pe-5" id="regPwd" v-model="form.password" required minlength="10">
<label for="regPwd" :class="{ 'active': form.password.length > 0 }">Password (min. 10 crt)</label>
<button
type="button"
class="btn bg-transparent border-0 shadow-none position-absolute p-2"
style="right: 0; top: 2px; z-index: 10;"
@click="showPassword = !showPassword"
:aria-pressed="showPassword"
aria-label="Mostra/Nascondi password"
>
<BIIcon :name="showPassword ? 'it-password-visible' : 'it-password-invisible'" color="primary" size="sm" />
</button>
</div>
</div>
</div>
<div 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>
<!-- Privacy Checkbox -->
<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>
</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>
/* Previene che il testo finisca sotto l'icona */
.pe-5 {
padding-right: 3rem !important;
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<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(() => {
const role = user.value?.role?.toUpperCase()
if (role === 'CITIZEN') return CitizenDashboard
if (role === 'OPERATOR') return OperatorDashboard
if (role === 'ADMIN') return AdminDashboard
return null
})
const heroTitle = computed(() => {
const role = user.value?.role?.toUpperCase()
if (role === 'ADMIN') return 'Pannello Admin'
if (role === 'OPERATOR') return 'Gestione Operativa'
return 'Dashboard'
})
const heroSubtitle = computed(() => {
const role = user.value?.role?.toUpperCase()
if (role === 'ADMIN') return 'Accesso amministrativo per configurazioni, utenti e moderazione'
if (role === 'OPERATOR') 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>
+4 -4
View File
@@ -19,10 +19,10 @@ const { data: openDataResponse } = await useAsyncData('recent-fixes', () =>
<div class="it-hero-wrapper bg-light">
<div class="container">
<div class="row">
<div class="col-12 col-lg-8 offset-lg-2 text-center">
<div class="col-12 col-lg-8 mx-auto text-center">
<div class="it-hero-text-wrapper p-4">
<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">
<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
e monitora l'intervento in totale trasparenza.
</p>
@@ -68,7 +68,7 @@ const { data: openDataResponse } = await useAsyncData('recent-fixes', () =>
</div>
</div>
<div class="bg-light py-5 border-top border-bottom">
<div class="bg-light py-5">
<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">
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
// La pagina è statica, usiamo solo i componenti UI
useHead({
title: 'Informazioni sul Progetto - Visione',
})
</script>
<template>
<div>
<!-- HERO SECTION (Senza pt-5/mt-5 come richiesto) -->
<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 per la gestione intelligente delle segnalazioni urbane,
pensata per avvicinare cittadini e pubblica amministrazione.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- OBIETTIVO DEL PROGETTO -->
<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">Migliorare la qualità della vita in città</h2>
<p class="lead text-muted">
Visione nasce dall'esigenza di fornire alle amministrazioni comunali uno strumento moderno per mappare, gestire e risolvere disservizi presenti sul territorio (buche stradali, illuminazione guasta, rifiuti abbandonati).
</p>
<p>
Il sistema non si limita a raccogliere dati, ma automatizza l'assegnazione alle squadre operative competenti tramite logiche geospaziali, traccia l'intero ciclo di vita della segnalazione e restituisce trasparenza ai cittadini tramite un portale Open Data dedicato.
</p>
</div>
</div>
</div>
<!-- RUOLI E FUNZIONALITÀ (Card Grid) -->
<div class="bg-light py-5">
<div class="container">
<h2 class="text-center mb-5 font-weight-bold">Come funziona il sistema</h2>
<div class="row">
<!-- Cittadino -->
<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 name="it-user" size="xl" color="primary" class="mb-3" />
<h3 class="card-title h5 font-weight-bold">Cittadini</h3>
<p class="card-text text-muted">
Tramite autenticazione sicura (OTP via email o CIE), i cittadini possono inviare segnalazioni geolocalizzate complete di foto.
<br><br>
Per incentivare la partecipazione, è integrato un sistema di <strong>gamification</strong>: ogni segnalazione valida e risolta fa guadagnare Punti Cittadino, premiando la cittadinanza attiva. Possono inoltre chattare con gli operatori e lasciare un feedback finale.
</p>
</div>
</div>
</div>
</div>
<!-- Operatore -->
<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 name="it-tool" size="xl" color="primary" class="mb-3" />
<h3 class="card-title h5 font-weight-bold">Operatori e Squadre</h3>
<p class="card-text text-muted">
Il personale comunale visualizza una dashboard con le segnalazioni in ingresso. Possono prenderle in carico, comunicare con l'utente e assegnarle alle squadre di intervento sul territorio.
<br><br>
Il sistema gestisce l'intero flusso di stato: da <em>Aperta</em> a <em>In Revisione</em>, fino a <em>In Lavorazione</em> e <em>Risolta</em>.
</p>
</div>
</div>
</div>
</div>
<!-- Amministratore -->
<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 name="it-settings" size="xl" color="primary" class="mb-3" />
<h3 class="card-title h5 font-weight-bold">Amministrazione</h3>
<p class="card-text text-muted">
Gli amministratori hanno il controllo completo della piattaforma. Possono definire le aree urbane tramite poligoni GeoJSON, gestire i permessi degli utenti, supervisionare i feedback e moderare eventuali abusi.
<br><br>
Hanno inoltre accesso a statistiche avanzate per individuare le criticità sul territorio e monitorare i tempi medi di risoluzione.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- STACK TECNOLOGICO E FILOSOFIA -->
<div class="container my-5 py-4">
<div class="row align-items-center">
<div class="col-12 col-lg-6 mb-4 mb-lg-0">
<h2 class="font-weight-bold mb-4">Tecnologia e Indipendenza</h2>
<ul class="it-list">
<li>
<BIIcon name="it-code-circle" color="primary" class="me-2" />
<strong>Architettura:</strong> Frontend reattivo e multipiattaforma sviluppato in <em>Vue.js/Nuxt 3</em>. Backend ad altissime prestazioni scritto in <em>C++</em> utilizzando il framework <em>Drogon</em>.
</li>
<li class="mt-3">
<BIIcon name="it-lock" color="primary" class="me-2" />
<strong>Sicurezza:</strong> Login Passwordless (OTP nativo) e sistema di autorizzazione Role-Based basato su token JWT.
</li>
<li class="mt-3">
<BIIcon name="it-piu" color="primary" class="me-2" />
<strong>Open Data:</strong> Piena aderenza alle linee guida AGID sulla trasparenza, con esportazione di dati anonimizzati accessibili al pubblico.
</li>
</ul>
</div>
<div class="col-12 col-lg-5 offset-lg-1">
<!-- Callout Ribellione Gitea -->
<div class="callout callout-highlight callout-warning">
<div class="callout-title">
<BIIcon name="it-warning-circle" />
Un approccio etico al codice sorgente
</div>
<p>
In risposta alle policy sempre più aggressive delle grandi piattaforme commerciali riguardo l'addestramento non consensuale di modelli di Intelligenza Artificiale sui repository privati, lo sviluppo di questo progetto è ospitato su un'istanza <strong>self-hosted di Gitea</strong>.
<br><br>
Una scelta di principio per garantire il controllo totale, la privacy e la vera sovranità sui dati e sul codice.
</p>
</div>
</div>
</div>
</div>
<!-- CREDITI ACCADEMICI (Footer Style Callout) -->
<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-8">
<h4 class="h5 font-weight-bold text-primary mb-2">Crediti Accademici</h4>
<p class="mb-1">
Questo progetto è stato realizzato nell'ambito del corso di <strong>Laboratorio di Programmazione</strong> (tenuto dal Prof. Vessio).
</p>
<p class="text-muted small mb-0">
Università degli Studi di Bari "Aldo Moro" - Dipartimento di Informatica.
</p>
</div>
<div class="col-12 col-md-4 mt-3 mt-md-0 text-md-end">
<p class="font-weight-bold mb-1">Sviluppato da:</p>
<ul class="list-unstyled mb-0">
<li>Renato Loiacono</li>
<li>Michelangelo Parisi</li>
<li>Gabriele Schiraldi</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Leggere correzioni stilistiche per le liste */
.it-list {
list-style-type: none;
padding-left: 0;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
useHead({
title: 'Note Legali - Visione',
})
</script>
<template>
<div>
<!-- HERO SECTION -->
<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>
<!-- CONTENUTO PRINCIPALE -->
<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>
<!-- DISCLAIMER ACCADEMICO -->
<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>
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
useHead({ title: 'Notifiche - Visione' })
const { fetchNotifications, markNotificationRead } = 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 })
)
const notifications = computed(() => notificationsData.value?.notifications || [])
const totalNotifications = computed(() => notificationsData.value?.notifications?.length || 0)
const unreadCount = computed(() => notifications.value.filter((n: any) => !n.read_at).length)
const handlePageChange = (newPage: number) => {
page.value = newPage
}
const handleMarkAsRead = async (notificationId: number) => {
try {
await markNotificationRead(notificationId)
await refreshNotifications()
} catch (error) {
console.error('Error marking notification as read:', error)
}
}
const handleNotificationClick = async (notification: any) => {
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>
<!-- HERO -->
<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>
<!-- CONTENUTO -->
<div class="container mb-5">
<div class="row">
<div class="col-12 col-lg-10 offset-lg-1">
<!-- FILTRI (Opzionale) -->
<div class="d-flex gap-2 mb-4">
<button class="btn btn-sm btn-outline-primary active">Tutti</button>
<button class="btn btn-sm btn-outline-secondary">Solo non letti</button>
</div>
<!-- LISTA NOTIFICHE -->
<div v-if="notifications.length === 0" class="text-center py-5">
<BIIcon name="it-bell" size="xl" color="info" class="mb-3 d-block" />
<h6 class="text-muted">Nessuna notifica</h6>
<p class="small text-muted">Quando riceverai nuovi aggiornamenti sulle tue segnalazioni, appariranno qui.</p>
</div>
<div v-else class="list-group">
<button
v-for="notification in notifications"
:key="notification.id"
type="button"
class="list-group-item list-group-item-action border-0 p-3 mb-3 rounded-2"
:class="{
'bg-light': !notification.read_at,
'bg-white': notification.read_at,
}"
@click="handleNotificationClick(notification)"
>
<div class="d-flex align-items-start gap-3">
<!-- ICONA -->
<div class="flex-shrink-0 mt-1">
<div class="rounded-circle p-2" :class="`bg-${getNotificationColor(notification.type)}`" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;">
<BIIcon :name="getNotificationIcon(notification.type)" size="sm" color="white" />
</div>
</div>
<!-- CONTENUTO -->
<div class="flex-grow-1">
<div class="d-flex align-items-start justify-content-between">
<div>
<h6 class="mb-1 font-weight-bold" :class="{ 'text-dark': !notification.read_at }">
{{ notification.title }}
</h6>
<p class="mb-1 small" :class="{ 'text-muted': notification.read_at }">
{{ notification.body }}
</p>
<small class="text-muted">
{{ formatDate(notification.created_at) }}
</small>
</div>
<!-- INDICATORE NON LETTO -->
<div v-if="!notification.read_at" class="flex-shrink-0">
<span class="badge bg-primary rounded-pill">Nuovo</span>
</div>
</div>
<!-- AZIONI RAPIDE -->
<div v-if="notification.report_id" class="mt-2 d-flex gap-2">
<NuxtLink :to="`/reports/${notification.report_id}`" class="btn btn-sm btn-outline-primary">
<BIIcon name="it-arrow-right" size="xs" class="me-1" />
Vedi segnalazione
</NuxtLink>
<button
v-if="!notification.read_at"
type="button"
class="btn btn-sm btn-outline-secondary"
@click.stop="handleMarkAsRead(notification.id)"
>
Segna come letta
</button>
</div>
</div>
</div>
</button>
</div>
<!-- PAGINATION -->
<Pagination
v-if="notifications.length > 0"
:current-page="page"
:per-page="perPage"
:total-items="totalNotifications"
@page-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.list-group-item {
border: 1px solid #e9ecef;
transition: all 0.2s ease;
cursor: pointer;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
&.bg-light {
border-left: 4px solid #006f9d;
}
}
.rounded-2 {
border-radius: 0.5rem;
}
</style>
+318
View File
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { Map as LeafletMap, CircleMarker, LatLngBoundsExpression } from 'leaflet'
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
// 1. API Fetching Logic
const { getOpenDataReports } = usePublic()
// Reactive pagination state
const currentPage = ref(1)
const perPage = ref(9)
// Fetch data automatically when currentPage changes
const { data: openDataResponse, pending } = await useAsyncData(
'open-data-reports',
() => getOpenDataReports({ page: currentPage.value, per_page: perPage.value }),
{ watch: [currentPage] } // Nuxt will re-fetch when currentPage changes
)
const visibleReports = computed(() =>
(openDataResponse.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude))
)
// Pagination logic: If we receive less items than `perPage`, we are on the last page.
const isLastPage = computed(() => {
if (!openDataResponse.value?.reports) return true
return openDataResponse.value.reports.length < perPage.value
})
const nextPage = () => {
if (!isLastPage.value) currentPage.value++
}
const prevPage = () => {
if (currentPage.value > 1) currentPage.value--
}
// 2. View Toggle (Grid vs Map)
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[] = []
// 3. Helper to style status badges
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 (visibleReports.value.length === 0) {
map.setView(BARI_CENTER, 12)
return
}
visibleReports.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 = visibleReports.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: '&copy; OpenStreetMap contributors'
}).addTo(map)
await renderMapMarkers()
}
watch(currentView, async (view) => {
if (view !== 'map') return
await nextTick()
await initMap()
map?.invalidateSize()
await renderMapMarkers()
})
watch(visibleReports, async () => {
if (currentView.value === 'map' && map) {
await renderMapMarkers()
}
})
onBeforeUnmount(() => {
clearMarkers()
map?.remove()
map = null
})
</script>
<template>
<div>
<!-- HERO SECTION (Small version for internal pages) -->
<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>
<!-- MAIN CONTENT -->
<div class="container my-5">
<!-- TOOLBAR: View Toggles & Export -->
<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">
<!-- View Toggle Buttons -->
<button
class="btn btn-sm"
:class="currentView === 'grid' ? 'btn-primary' : 'btn-outline-primary'"
@click="currentView = 'grid'"
>
<BIIcon name="it-grid" size="sm" class="me-1" :color="currentView === 'grid' ? 'white' : 'primary'" />
Griglia Dati
</button>
<button
class="btn btn-sm"
:class="currentView === 'map' ? 'btn-primary' : 'btn-outline-primary'"
@click="currentView = 'map'"
>
<BIIcon name="it-map-marker" size="sm" class="me-1" :color="currentView === 'map' ? 'white' : 'primary'" />
Mappa
</button>
</div>
<div class="col-12 col-md-6 text-md-end">
<!-- Fake Export CTA (Very common in Open Data portals) -->
<button class="btn btn-sm btn-outline-secondary" title="Scarica dataset in formato JSON">
<BIIcon name="it-download" size="sm" class="me-1" />
Scarica JSON
</button>
</div>
</div>
<!-- LOADER -->
<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>
<!-- GRID VIEW -->
<div v-else-if="currentView === 'grid'">
<div class="row" v-if="openDataResponse?.reports && openDataResponse.reports.length > 0">
<div class="col-12 col-md-6 col-lg-4 mb-4" v-for="report in visibleReports" :key="report.id">
<!-- Strict BI Card -->
<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="badge rounded-pill text-white px-3 py-1" :class="getStatusBadge(report.status).class">
<BIIcon :name="getStatusBadge(report.status).icon" size="xs" color="white" class="me-1" />
{{ getStatusBadge(report.status).text }}
</span>
</div>
<span class="text-muted small">ID: #{{ report.id }}</span>
</div>
<h3 class="card-title h5 font-weight-bold">{{ report.category_name }}</h3>
<p class="card-text text-muted mb-4">
<BIIcon name="it-map-marker-circle" size="sm" class="me-1" />
{{ 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>
<!-- BOOTSTRAP ITALIA PAGINATION -->
<nav class="pagination-wrapper justify-content-center mt-5" aria-label="Navigazione pagine open data">
<ul class="pagination">
<li class="page-item" :class="{ disabled: currentPage === 1 }">
<a class="page-link" href="#" tabindex="-1" aria-disabled="true" @click.prevent="prevPage">
<BIIcon name="it-chevron-left" size="sm" />
<span class="visually-hidden">Pagina precedente</span>
</a>
</li>
<li class="page-item">
<span class="page-link font-weight-semibold">Pagina {{ currentPage }}</span>
</li>
<li class="page-item" :class="{ disabled: isLastPage }">
<a class="page-link" href="#" @click.prevent="nextPage">
<span class="visually-hidden">Pagina successiva</span>
<BIIcon name="it-chevron-right" size="sm" />
</a>
</li>
</ul>
</nav>
</div>
<!-- MAP VIEW -->
<div v-else-if="currentView === 'map'" class="map-view-wrapper">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<p class="text-muted mb-0">Mappa pubblica delle segnalazioni nell'area di Bari.</p>
<span class="badge bg-light text-dark border">{{ visibleReports.length }} punti</span>
</div>
<ClientOnly>
<div ref="mapContainer" class="open-data-map rounded border"></div>
</ClientOnly>
</div>
</div>
</div>
</template>
<style scoped>
/* Ensures the cards inside the grid stretch to equal heights */
.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>
+56
View File
@@ -0,0 +1,56 @@
<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">Coda Open</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 > perPage"
:current-page="page"
:per-page="perPage"
:total-items="reportsRes.total"
@page-change="page = $event"
/>
</div>
</div>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
auth: { role: 'OPERATOR' }
})
useHead({ title: 'Segnalazioni Aperte - Operatore' })
const { getOpenReports, takeReport } = useOperator()
const page = ref(1)
const perPage = ref(10)
const { data: reportsRes, pending, refresh } = useAsyncData(
'operator-open-reports',
() => getOpenReports({ page: page.value, per_page: perPage.value }),
{ watch: [page] }
)
const handleTakeReport = async (id: number) => {
try {
const res = await takeReport(id)
if (res.ok) {
await refresh()
}
} catch (err) {
console.error('Failed to take report', err)
}
}
</script>
<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 prendere in carico (OPEN)</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 aperte in questo momento.
</div>
<div v-else>
<ReportTable
:reports="reportsRes.reports"
action-variant="manage"
detail-base-path="/operator/reports"
@take="handleTakeReport"
/>
<Pagination
v-if="reportsRes.total > perPage"
:current-page="page"
:per-page="perPage"
:total-items="reportsRes.total"
@page-change="page = $event"
/>
</div>
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
<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">Coda Open</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>
+632
View File
@@ -0,0 +1,632 @@
<script setup lang="ts">
import type { Message, 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 } = useReports()
const { getInterventionSummary, updateReportStatus, assignTeam, getTeams, createInterventionLink, revokeInterventionLink } = useOperator()
const { user } = useAuth()
// Dati principali
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 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 summary = computed(() => (summaryData.value as any)?.summary)
const summaryMedia = computed(() => (summaryData.value as any)?.media || [])
const teams = computed(() => teamsData.value?.teams || [])
const statusMeta: Record<string, string> = {
OPEN: 'Aperta',
UNDER_REVIEW: 'In revisione',
ASSIGNED: 'Assegnata',
IN_PROGRESS: 'In lavorazione',
RESOLVED: 'Risolta',
REJECTED: 'Respinta',
CLOSED: 'Chiusa'
}
// Form Cambia Stato
const newStatus = ref<ReportStatus>('IN_PROGRESS')
const statusNote = ref('')
const isUpdatingStatus = ref(false)
// Form Assegna Squadra
const isAssignModalOpen = ref(false)
const selectedTeamId = ref<number | null>(null)
const isAssigning = ref(false)
const interventionLink = ref('')
const interventionLinkExpiresAt = ref('')
const isGeneratingInterventionLink = ref(false)
const isRevokingInterventionLink = ref(false)
const interventionLinkError = ref('')
const interventionLinkSuccess = ref('')
const isRevokeInterventionModalOpen = ref(false)
// Messaggi
const isSubmittingMessage = ref(false)
const isFlaggingMessageId = ref<number | null>(null)
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: 'Chiusa' }
]
// Inizializza lo stato nel form
watch(() => report.value?.status, (val) => {
if (val) newStatus.value = val as ReportStatus
}, { immediate: true })
const handleStatusUpdate = async () => {
isUpdatingStatus.value = true
try {
const res = await updateReportStatus(reportId, {
status: newStatus.value,
note: statusNote.value || undefined
})
if (res.ok) {
statusNote.value = ''
await Promise.all([refreshReport(), refreshSummary()])
}
} catch (e) {
console.error('Failed to update status', e)
} finally {
isUpdatingStatus.value = false
}
}
const handleAssignTeam = async () => {
if (!selectedTeamId.value) return
isAssigning.value = true
try {
const res = await assignTeam(reportId, {
team_id: selectedTeamId.value
})
if (res.ok) {
isAssignModalOpen.value = false
selectedTeamId.value = null
await Promise.all([refreshReport(), refreshSummary()])
}
} catch (e) {
console.error('Failed to assign team', e)
} finally {
isAssigning.value = false
}
}
const copyInterventionLink = async () => {
if (!interventionLink.value || !navigator?.clipboard) return
try {
await navigator.clipboard.writeText(interventionLink.value)
interventionLinkSuccess.value = 'Link copiato negli appunti.'
interventionLinkError.value = ''
} catch {
interventionLinkError.value = 'Copia automatica non riuscita.'
interventionLinkSuccess.value = ''
}
}
const handleCreateInterventionLink = async () => {
isGeneratingInterventionLink.value = true
interventionLinkError.value = ''
interventionLinkSuccess.value = ''
try {
const res = await createInterventionLink(reportId)
if (res.ok) {
interventionLink.value = res.url
interventionLinkExpiresAt.value = res.expires_at
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
interventionLinkError.value = ''
interventionLinkSuccess.value = ''
try {
const res = await revokeInterventionLink(reportId)
if (res.ok) {
interventionLink.value = ''
interventionLinkExpiresAt.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 (!msgText.trim() && files.length === 0) return
isSubmittingMessage.value = true
try {
await addMessage(reportId, { body: msgText })
await refreshMessages()
} catch (e) {
console.error('Failed to send message', e)
} finally {
isSubmittingMessage.value = false
}
}
const canFlagMessage = (message: Message) => {
if (!user.value) return false
return message.user_id !== user.value.id && message.user_id === report.value?.user_id
}
const handleFlagMessage = async (message: Message) => {
if (!canFlagMessage(message)) return
isFlaggingMessageId.value = message.id
try {
await flagMessage(reportId, message.id, {
reason: 'Messaggio segnalato da operatore responsabile per verifica moderazione',
})
} catch (e) {
console.error('Failed to flag message', e)
} finally {
isFlaggingMessageId.value = null
}
}
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="/operator/assigned" class="btn btn-sm btn-link text-decoration-none ps-0 mb-2">
&larr; Torna alle assegnazioni
</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">Intervento #{{ reportId }}</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] || 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>
</div>
<div class="container my-5">
<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>
</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"
/>
<MessageComposer @send="handleSendMessage" />
</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 disponibili</span>
<strong>{{ teams.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">
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="teams.length === 0">
Assegna team
</button>
</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="interventionLink">
<label class="form-label">URL attivo</label>
<div class="input-group mb-2">
<input type="text" class="form-control" :value="interventionLink" readonly />
<button type="button" class="btn btn-outline-primary" @click="copyInterventionLink">
Copia
</button>
</div>
<p class="small text-muted mb-3">
Scade: {{ formattedInterventionLinkExpiry || '-' }}
</p>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary" :disabled="isGeneratingInterventionLink" @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"
@click="handleCreateInterventionLink"
>
<span v-if="isGeneratingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
Genera link
</button>
</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>
<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 availableStatuses" :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 || newStatus === report.status">
<span v-if="isUpdatingStatus" class="spinner-border spinner-border-sm me-2"></span>
Aggiorna Stato
</button>
</form>
</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>
<!-- Modale Assegnazione -->
<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;">
<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">
<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>
</select>
<small v-if="teams.length === 0" class="form-text text-danger">
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>
</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"
/>
</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>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
useHead({
title: 'Privacy Policy - Visione',
})
</script>
<template>
<div>
<!-- HERO SECTION -->
<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>
<!-- CONTENUTO PRINCIPALE -->
<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 (incluso login Passwordless 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>
+156
View File
@@ -0,0 +1,156 @@
<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">{{ 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 to="/reports/new" class="btn btn-primary">Nuova segnalazione</NuxtLink>
<NuxtLink to="/notifications" class="btn btn-outline-primary">Notifiche</NuxtLink>
<NuxtLink to="/auth/change-password" class="btn btn-outline-secondary">Cambia password</NuxtLink>
<button type="button" class="btn btn-outline-danger" @click="logout">
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>
+462
View File
@@ -0,0 +1,462 @@
<script setup lang="ts">
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('/')
}
// Fetch dati
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 newMessage = ref('')
const isSubmittingMessage = ref(false)
const isFeedbackOpen = ref(false)
const feedbackRating = ref(5)
const feedbackComment = ref('')
const isSubmittingFeedback = ref(false)
const selectedMediaIndex = ref(0)
const isGalleryOpen = ref(false)
// Invia messaggio
const handleSendMessage = async (msgText: string, files: File[]) => {
if (!msgText.trim() && files.length === 0) return
isSubmittingMessage.value = true
try {
// Gestione mock di file, nella realtà si usa FormData o simle
await addMessage(reportId, { body: msgText })
await refreshMessages()
} catch (error) {
console.error('Error sending message:', error)
} finally {
isSubmittingMessage.value = false
}
}
// Invia feedback (solo se risolta)
const handleSubmitFeedback = async () => {
isSubmittingFeedback.value = true
try {
await addFeedback(reportId, {
rating: feedbackRating.value,
comment: feedbackComment.value || undefined,
})
isFeedbackOpen.value = false
feedbackRating.value = 5
feedbackComment.value = ''
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' || report.value?.status === 'CLOSED')
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: 'Chiusa', 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 name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
<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 name="it-camera" size="sm" color="primary" class="me-2" />
<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 :src="runtimeConfig.public.api + m.url" :alt="`Allegato ${Number(idx) + 1}`" 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 name="it-video" size="xl" color="white" />
</div>
</div>
</div>
</div>
</div>
<div v-if="statusHistory.length > 0" class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<div class="report-section-head mb-4">
<BIIcon name="it-history" size="sm" color="primary" class="me-2" />
<h2 class="h6 mb-0">Cronologia</h2>
</div>
<div class="timeline">
<div v-for="item in statusHistory" :key="item.id" class="timeline-item mb-3">
<div class="d-flex gap-3">
<div class="flex-shrink-0">
<div class="timeline-marker"></div>
</div>
<div class="flex-grow-1">
<small class="text-muted">{{ formatDate(item.created_at) }}</small>
<div class="small font-weight-semibold d-flex align-items-center gap-2 mt-1">
<StatusBadge :status="item.old_status" />
<BIIcon name="it-arrow-right" size="xs" />
<StatusBadge :status="item.new_status" />
</div>
<small v-if="item.note" class="text-muted d-block">{{ item.note }}</small>
</div>
</div>
</div>
</div>
</div>
</div>
<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 name="it-comment" size="sm" color="primary" class="me-2" />
<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 @send="handleSendMessage" />
</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>
<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 type="button" class="btn btn-primary" @click="handleSubmitFeedback" :disabled="isSubmittingFeedback">
<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>
</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;
}
.timeline-marker {
width: 12px;
height: 12px;
background-color: #006f9d;
border-radius: 50%;
border: 3px solid #f0f0f0;
}
.timeline-item:not(:last-child) .timeline-marker::after {
content: '';
position: absolute;
left: 50%;
top: 20px;
width: 2px;
height: calc(100% + 20px);
background-color: #006f9d;
transform: translateX(-50%);
}
.ratio {
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>
+118
View File
@@ -0,0 +1,118 @@
<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>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import type { CreateReportRequest } from '~/types/api'
definePageMeta({
middleware: 'auth',
})
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>
+199
View File
@@ -0,0 +1,199 @@
<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>
+20
View File
@@ -0,0 +1,20 @@
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
View File
@@ -1,3 +1,4 @@
export default defineNuxtPlugin(async (nuxtApp) => {
// @ts-expect-error
await import('bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js')
})
+37
View File
@@ -208,6 +208,12 @@ export interface ChangePasswordRequest {
new_password: string;
}
export interface UpdateProfileRequest {
first_name?: string;
last_name?: string;
phone?: string;
}
export interface EmailRequest {
email: string;
}
@@ -261,11 +267,24 @@ export interface CreateAreaRequest {
description?: string;
}
export interface UpdateAreaRequest {
name?: string;
boundary_geojson?: GeoJsonObject;
description?: string;
active?: boolean;
}
export interface CreateCategoryRequest {
name: string;
description?: string;
}
export interface UpdateCategoryRequest {
name?: string;
description?: string;
active?: boolean;
}
export interface CreateTeamRequest {
team_category_id: number;
name: string;
@@ -273,6 +292,14 @@ export interface CreateTeamRequest {
area_id?: number;
}
export interface UpdateTeamRequest {
team_category_id?: number;
name?: string;
description?: string;
area_id?: number | null;
active?: boolean;
}
export interface UpdateUserBanRequest {
banned: boolean;
}
@@ -315,6 +342,7 @@ export interface PagedReportListResponse {
ok: boolean;
page: number;
per_page: number;
total?: number;
reports: Report[];
}
@@ -339,6 +367,12 @@ export interface InterventionSummaryResponse {
latest_messages: Message[];
}
export interface AssignmentLinkResponse {
ok: boolean;
url: string;
expires_at: string;
}
export interface MediaUploadResponse {
ok: boolean;
media: Media[];
@@ -393,6 +427,7 @@ export interface ModerationListResponse {
ok: boolean;
page: number;
per_page: number;
total?: number;
flags: ModerationFlag[];
}
@@ -406,4 +441,6 @@ export interface OverviewStatsResponse {
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;
}
+8
View File
@@ -0,0 +1,8 @@
declare module '#app' {
interface PageMeta {
auth?: {
role?: 'CITIZEN' | 'OPERATOR' | 'ADMIN'
}
}
}
export {}
+15
View File
@@ -0,0 +1,15 @@
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
+103
View File
@@ -0,0 +1,103 @@
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)
}
+14
View File
@@ -0,0 +1,14 @@
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
+7 -4
View File
@@ -8,6 +8,8 @@ export default defineNuxtConfig({
'@nuxtjs/sitemap',
'@nuxtjs/robots'
],
// TODO: riattivare prima di pubblicare il sito
ssr: false,
runtimeConfig: {
public: {
@@ -17,13 +19,14 @@ export default defineNuxtConfig({
},
site: {
url: 'http://localhost:3000',
url: 'https://visione.studentindipendenti.it',
name: 'Visione - Un\'occhio per la sicurezza del cittadino.',
},
css: [
'@/assets/css/style.css',
'bootstrap-italia/dist/css/bootstrap-italia.min.css'
'leaflet/dist/leaflet.css',
'bootstrap-italia/dist/css/bootstrap-italia.min.css',
'@/assets/css/style.css'
],
fonts: {
@@ -32,7 +35,7 @@ export default defineNuxtConfig({
{ name: 'Roboto Mono', provider: 'google' },
{ name: 'Titillium Web', provider: 'google' }
]
}
},
app: {
head: {
+4 -1
View File
@@ -15,11 +15,14 @@
"@nuxtjs/robots": "6.0.9",
"@nuxtjs/sitemap": "8.0.15",
"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"
"@nuxt/fonts": "^0.14.0",
"@types/leaflet": "^1.9.21"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

+682 -653
View File
File diff suppressed because it is too large Load Diff