Compare commits
15
Commits
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
# Public URL of the website
|
NUXT_PUBLIC_URL=http://localhost:3000
|
||||||
NUXT_SITE_URL=http://localhost:3000
|
NUXT_PUBLIC_API=http://localhost:5555
|
||||||
|
|||||||
+28
@@ -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"]
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
a {
|
a {
|
||||||
text-decoration: none!important;
|
text-decoration: none!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
|
||||||
|
}
|
||||||
@@ -47,8 +47,8 @@ const { sortedMembers } = useMembers();
|
|||||||
<div class="it-footer-small-prints clearfix">
|
<div class="it-footer-small-prints clearfix">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<ul class="it-footer-small-prints-list list-inline mb-0 d-flex flex-column flex-md-row">
|
<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="/note-legali">Note legali</a></li>
|
||||||
<li class="list-inline-item"><a href="#">Privacy policy</a></li>
|
<li class="list-inline-item"><a href="/privacy">Privacy policy</a></li>
|
||||||
<li class="list-inline-item"><a href="/sitemap.xml">Mappa del sito</a></li>
|
<li class="list-inline-item"><a href="/sitemap.xml">Mappa del sito</a></li>
|
||||||
<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>
|
<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>
|
</ul>
|
||||||
|
|||||||
+57
-20
@@ -1,10 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import BIIcon from "~/components/BIIcon.vue";
|
import BIIcon from "~/components/BIIcon.vue";
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const isMobileMenuOpen = ref(false)
|
const isMobileMenuOpen = ref(false)
|
||||||
|
|
||||||
watch(() => route.path, () => {
|
watch(() => route.path, () => {
|
||||||
@@ -18,7 +17,7 @@ const navLinks = [
|
|||||||
{ name: 'Come funziona', path: '/info' }
|
{ name: 'Come funziona', path: '/info' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const isAuthenticated = ref(false) // Replace with: const token = useCookie('auth_token'); computed(() => !!token.value)
|
const { isAuthenticated, user, logout } = useAuth()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -28,33 +27,57 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="it-header-slim-wrapper-content">
|
<div class="it-header-slim-wrapper-content">
|
||||||
<a class="d-none d-lg-block navbar-brand" 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.interno.gov.it/it/ministero/dipartimenti/dipartimento-pubblica-sicurezza">
|
||||||
|
Dipartimento della Pubblica Sicurezza
|
||||||
|
</a>
|
||||||
<div class="nav-mobile">
|
<div class="nav-mobile">
|
||||||
<nav aria-label="Navigazione secondaria">
|
<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" >
|
<a class="it-opener d-lg-none" data-bs-toggle="collapse"
|
||||||
<span>Dipartmento della Pubblica Sicureazza</span>
|
href="https://www.interno.gov.it/it/ministero/dipartimenti/dipartimento-pubblica-sicurezza"
|
||||||
<BIIcon name="it-expand" />
|
role="button" aria-expanded="false">
|
||||||
|
<span>Dipartimento della Pubblica Sicurezza</span>
|
||||||
|
<BIIcon name="it-expand"/>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="it-header-slim-right-zone">
|
|
||||||
<div class="it-access-top-wrapper">
|
<div class="header-slim-right-zone" role="navigation">
|
||||||
<!-- Dynamic Login / Area Personale Button -->
|
<!-- Utente Autenticato: Bottone Utente + Bottone Esci -->
|
||||||
<NuxtLink v-if="!isAuthenticated" class="btn btn-primary btn-sm" to="/auth/login">
|
<div v-if="isAuthenticated" class="d-flex align-items-center gap-2">
|
||||||
Accedi
|
<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-user" />
|
||||||
|
</span>
|
||||||
|
<span class="d-none d-lg-block">{{ user?.first_name }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink v-else class="btn btn-primary btn-sm" to="/dashboard">
|
|
||||||
Area Personale
|
<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" /> <!-- usa it-unlocked se it-logout non è presente -->
|
||||||
|
</span>
|
||||||
|
<span class="d-none d-lg-block">Esci</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Utente NON Autenticato: Bottone Login -->
|
||||||
|
<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" />
|
||||||
|
</span>
|
||||||
|
<span class="d-none d-lg-block">Login</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CENTER HEADER (Brand) -->
|
|
||||||
<div class="it-nav-wrapper">
|
<div class="it-nav-wrapper">
|
||||||
<div class="it-header-center-wrapper">
|
<div class="it-header-center-wrapper">
|
||||||
<div class="container-xxl">
|
<div class="container-xxl">
|
||||||
@@ -63,10 +86,10 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
|
|||||||
<div class="it-header-center-content-wrapper">
|
<div class="it-header-center-content-wrapper">
|
||||||
<div class="it-brand-wrapper">
|
<div class="it-brand-wrapper">
|
||||||
<NuxtLink to="/">
|
<NuxtLink to="/">
|
||||||
<BIIcon name="it-pa" />
|
<BIIcon name="it-pa"/>
|
||||||
<div class="it-brand-text">
|
<div class="it-brand-text">
|
||||||
<div class="it-brand-title">Visione</div>
|
<div class="it-brand-title">Visione</div>
|
||||||
<div class="it-brand-tagline d-none d-md-block">Un'occhio per la sicurezza del cittadino.</div>
|
<div class="it-brand-tagline d-none d-md-block">Un occhio per la sicurezza del cittadino.</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +110,7 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
|
|||||||
aria-label="Mostra la navigazione"
|
aria-label="Mostra la navigazione"
|
||||||
@click="isMobileMenuOpen = true"
|
@click="isMobileMenuOpen = true"
|
||||||
>
|
>
|
||||||
<BIIcon name="it-burger" />
|
<BIIcon name="it-burger"/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -106,7 +129,7 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
|
|||||||
<div class="close-div">
|
<div class="close-div">
|
||||||
<button class="btn close-menu" type="button" @click="isMobileMenuOpen = false">
|
<button class="btn close-menu" type="button" @click="isMobileMenuOpen = false">
|
||||||
<span class="visually-hidden">Nascondi la navigazione</span>
|
<span class="visually-hidden">Nascondi la navigazione</span>
|
||||||
<BIIcon name="it-close-big" />
|
<BIIcon name="it-close-big"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,11 +164,25 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.overlay {
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay.show {
|
.overlay.show {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<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, // 50MB
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Validazione numero file
|
||||||
|
if (files.value.length + newFiles.length > props.maxFiles) {
|
||||||
|
error.value = `Massimo ${props.maxFiles} file consentiti`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validazione dimensione
|
||||||
|
for (const file of newFiles) {
|
||||||
|
if (file.size > props.maxFileSize! * 1024 * 1024) {
|
||||||
|
error.value = `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 getMediaType = (file: File): string => {
|
||||||
|
return file.type.startsWith('video/') ? 'Video' : 'Immagine'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="media-uploader">
|
||||||
|
<!-- Area trascinamento -->
|
||||||
|
<div
|
||||||
|
class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer"
|
||||||
|
:class="{ 'border-primary': 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">clicca per selezionare</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
Massimo {{ maxFiles }} file, fino a {{ maxFileSize }}MB per file
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input nascosto -->
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
:multiple="props.maxFiles > 1"
|
||||||
|
:accept="acceptedTypes"
|
||||||
|
:disabled="disabled"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleFilesSelected(($event.target as HTMLInputElement).files)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Errori -->
|
||||||
|
<div v-if="error" class="alert alert-danger mt-3 mb-0" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista file -->
|
||||||
|
<div v-if="files.length > 0" class="mt-4">
|
||||||
|
<h6 class="font-weight-bold mb-3">File caricati ({{ files.length }}/{{ maxFiles }})</h6>
|
||||||
|
<div class="list-group">
|
||||||
|
<div v-for="(file, idx) in files" :key="`file-${idx}`" class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<BIIcon
|
||||||
|
:name="file.type.startsWith('video/') ? 'it-video' : 'it-image'"
|
||||||
|
size="lg"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="font-weight-semibold small">{{ file.name }}</div>
|
||||||
|
<small class="text-muted">{{ getMediaType(file) }} • {{ formatFileSize(file.size) }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-link p-0"
|
||||||
|
@click="removeFile(idx)"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="`Rimuovi ${file.name}`"
|
||||||
|
>
|
||||||
|
<BIIcon name="it-close-circle" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-area {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(.border-primary) {
|
||||||
|
background-color: rgba(0, 111, 157, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-2 {
|
||||||
|
border-width: 2px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Emits {
|
||||||
|
(e: 'send', message: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unica dichiarazione di defineEmits
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const message = ref('')
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (message.value.trim() === '') return
|
||||||
|
|
||||||
|
emit('send', message.value)
|
||||||
|
message.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
// Ctrl/Cmd + Enter per inviare
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
// Previene l'andata a capo predefinita della textarea
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="message-composer mt-4 border-top pt-3">
|
||||||
|
<!-- Non serve passare emit qui -->
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="input-group">
|
||||||
|
<textarea
|
||||||
|
v-model="message"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Scrivi un messaggio... (Ctrl+Enter per inviare)"
|
||||||
|
rows="2"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="isSubmitting || message.trim() === ''"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
💡 <strong>Tip:</strong> Premi Ctrl+Enter per inviare velocemente
|
||||||
|
</small>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-composer {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Message } from '~/types/api'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
messages: Message[]
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
isLoading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
</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="alert alert-info text-center" role="alert">
|
||||||
|
Nessun messaggio disponibile. Sii il primo a scrivere!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="messages-container">
|
||||||
|
<div v-for="msg in messages" :key="msg.id" class="message-bubble mb-3">
|
||||||
|
<div
|
||||||
|
class="message-bubble-content p-3 rounded-3"
|
||||||
|
:class="{
|
||||||
|
'bg-primary text-white': isCurrentUser(msg.user_id),
|
||||||
|
'bg-light border': !isCurrentUser(msg.user_id)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Nome mittente (solo per messaggi non miei) -->
|
||||||
|
<div v-if="!isCurrentUser(msg.user_id)" class="small font-weight-bold mb-2 text-primary">
|
||||||
|
{{ msg.first_name }} {{ msg.last_name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Corpo messaggio -->
|
||||||
|
<div class="message-body" :class="{ 'text-break': true }">
|
||||||
|
{{ msg.body }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timestamp -->
|
||||||
|
<div class="small mt-2" :class="{ 'text-white-50': isCurrentUser(msg.user_id), 'text-muted': !isCurrentUser(msg.user_id) }">
|
||||||
|
{{ formatDate(msg.created_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-list {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble-content {
|
||||||
|
max-width: 75%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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">«</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">»</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>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Category, CreateReportRequest } from '~/types/api'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isLoading?: boolean
|
||||||
|
categories?: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', report: CreateReportRequest, media: File[]): void
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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'
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
form.value.latitude = Number(position.coords.latitude.toFixed(6))
|
||||||
|
form.value.longitude = Number(position.coords.longitude.toFixed(6))
|
||||||
|
|
||||||
|
// Richiedi indirizzo da coordinate (reverse geocoding)
|
||||||
|
// Se il backend non lo fa, userà le coord così come sono
|
||||||
|
} catch (error: any) {
|
||||||
|
errors.value.location = 'Impossibile ottenere la posizione. Inserisci 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 = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Osserva cambio categoria
|
||||||
|
watch(() => form.value.category_id, () => {
|
||||||
|
if (errors.value.category_id) {
|
||||||
|
errors.value.category_id = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="report-form">
|
||||||
|
<!-- Successo -->
|
||||||
|
<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">
|
||||||
|
<!-- CATEGORIA -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- GEOLOCALIZZAZIONE -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div v-if="form.latitude && form.longitude" class="alert alert-info mb-3">
|
||||||
|
<strong>Coordinate:</strong> {{ form.latitude }}, {{ form.longitude }}
|
||||||
|
<button type="button" class="btn btn-sm btn-link float-end" @click="form.latitude = null; form.longitude = null">
|
||||||
|
Cancella
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="alert alert-warning mb-3 small" role="alert">
|
||||||
|
Se la geolocalizzazione non funziona, inserisci manualmente coordinate o indirizzo approssimativo.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<input
|
||||||
|
v-model.number="form.latitude"
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
class="form-control"
|
||||||
|
: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"
|
||||||
|
:class="{ 'is-invalid': errors.location }"
|
||||||
|
placeholder="Longitudine (es. 10.2)"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="errors.location" class="invalid-feedback d-block">
|
||||||
|
{{ errors.location }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- INDIRIZZO -->
|
||||||
|
<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>
|
||||||
|
<input
|
||||||
|
id="address"
|
||||||
|
v-model="form.address"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.address }"
|
||||||
|
placeholder="Es. Via Roma 123, Milano"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div v-if="errors.address" class="invalid-feedback">
|
||||||
|
{{ errors.address }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESCRIZIONE -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- MEDIA -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- BOTTONI -->
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Presumo tu abbia un useAdmin.ts (come da tuo elenco file) per le statistiche
|
||||||
|
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 di Amministrazione</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.data?.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.data?.open_reports || 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/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 GeoJSON
|
||||||
|
</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-4 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,250 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Report } from '~/types/api'
|
||||||
|
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { getReports } = useReports()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
const filterStatus = ref<string>('') // Stringa vuota = "Tutte"
|
||||||
|
|
||||||
|
// Carichiamo TUTTE le segnalazioni per avere statistiche globali e filtri veloci
|
||||||
|
const { data: reportsRes, pending } = useAsyncData('citizen-reports-all', () =>
|
||||||
|
getReports({}) // Non passiamo filtri alla chiamata API
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dati grezzi dal server
|
||||||
|
const rawReports = computed<Report[]>(() => reportsRes.value?.reports || [])
|
||||||
|
|
||||||
|
// 1. STATISTICHE GLOBALI (Sempre basate su tutti i dati, ignorano i filtri)
|
||||||
|
const statsGlobal = computed(() => {
|
||||||
|
const stats = { total: 0, OPEN: 0, IN_PROGRESS: 0, RESOLVED: 0 }
|
||||||
|
rawReports.value.forEach((report: Report) => {
|
||||||
|
stats.total++
|
||||||
|
if (report.status === 'OPEN') stats.OPEN++
|
||||||
|
if (report.status === 'IN_PROGRESS' || report.status === 'ASSIGNED') stats.IN_PROGRESS++
|
||||||
|
if (report.status === 'RESOLVED') stats.RESOLVED++
|
||||||
|
})
|
||||||
|
return stats
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. FILTRO LATO CLIENT (Rende il cambio filtro istantaneo)
|
||||||
|
const filteredReports = computed(() => {
|
||||||
|
if (!filterStatus.value) return rawReports.value
|
||||||
|
|
||||||
|
return rawReports.value.filter((report: Report) => {
|
||||||
|
// Raggruppiamo IN_PROGRESS e ASSIGNED sotto la stessa voce per l'utente
|
||||||
|
if (filterStatus.value === 'IN_PROGRESS') {
|
||||||
|
return report.status === 'IN_PROGRESS' || report.status === 'ASSIGNED'
|
||||||
|
}
|
||||||
|
return report.status === filterStatus.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. PAGINAZIONE LATO CLIENT
|
||||||
|
const paginatedReports = computed(() => {
|
||||||
|
const start = (page.value - 1) * perPage.value
|
||||||
|
const end = start + perPage.value
|
||||||
|
return filteredReports.value.slice(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalFiltered = computed(() => filteredReports.value.length)
|
||||||
|
|
||||||
|
// GESTIONE FILTRI (Evita reload se clicchi il filtro attivo)
|
||||||
|
const handleStatusFilter = (status: string) => {
|
||||||
|
const targetStatus = status === 'all' ? '' : status
|
||||||
|
|
||||||
|
// Se clicco sullo stesso filtro, non fare nulla
|
||||||
|
if (filterStatus.value === targetStatus) return
|
||||||
|
|
||||||
|
filterStatus.value = targetStatus
|
||||||
|
page.value = 1 // Reset alla prima pagina
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
page.value = newPage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers grafici
|
||||||
|
const getStatusColor = (status: string): string => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
OPEN: 'primary', UNDER_REVIEW: 'info', ASSIGNED: 'warning',
|
||||||
|
IN_PROGRESS: 'warning', RESOLVED: 'success', REJECTED: 'danger', CLOSED: 'secondary',
|
||||||
|
}
|
||||||
|
return colors[status] || 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
OPEN: 'Aperta', UNDER_REVIEW: 'In revisione', ASSIGNED: 'Assegnata',
|
||||||
|
IN_PROGRESS: 'In corso', RESOLVED: 'Risolta', REJECTED: 'Rifiutata', CLOSED: 'Chiusa',
|
||||||
|
}
|
||||||
|
return labels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return new Date(dateString).toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Callout Gamification -->
|
||||||
|
<div class="callout callout-highlight callout-success mb-4">
|
||||||
|
<div class="callout-title d-flex align-items-center">
|
||||||
|
<BIIcon name="it-star-full" size="sm" color="success" class="me-2" />
|
||||||
|
I tuoi Punti Cittadino
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Hai accumulato <strong>{{ user?.points || 0 }} punti</strong>! Continua ad aiutare la comunità segnalando i problemi urbani.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STATISTICHE RAPIDE (Basate su statsGlobal, non cambiano filtrando) -->
|
||||||
|
<div class="row mb-5">
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="card card-bg text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-6 text-primary font-weight-bold">{{ statsGlobal.total }}</div>
|
||||||
|
<small class="text-muted">Totali</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="card card-bg text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-6 text-warning font-weight-bold">{{ statsGlobal.OPEN }}</div>
|
||||||
|
<small class="text-muted">Aperte</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="card card-bg text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-6 text-info font-weight-bold">{{ statsGlobal.IN_PROGRESS }}</div>
|
||||||
|
<small class="text-muted">In corso</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="card card-bg text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-6 text-success font-weight-bold">{{ statsGlobal.RESOLVED }}</div>
|
||||||
|
<small class="text-muted">Risolte</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AZIONI RAPIDE E FILTRI -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||||||
|
<NuxtLink to="/reports/create" class="btn btn-primary me-md-3">
|
||||||
|
<BIIcon name="it-plus-circle" size="sm" color="white" class="me-2" />
|
||||||
|
Nuova segnalazione
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="progress-spinner progress-spinner-active my-5 mx-auto">
|
||||||
|
<span class="visually-hidden">Caricamento...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TABELLA SEGNALAZIONI -->
|
||||||
|
<div class="row" v-else>
|
||||||
|
<div class="col-12">
|
||||||
|
<div v-if="filteredReports.length === 0" class="alert alert-info text-center" role="alert">
|
||||||
|
<h6>Nessuna segnalazione trovata</h6>
|
||||||
|
<p class="small text-muted mb-0">Non ci sono segnalazioni con lo stato "{{ filterStatus || 'Tutte' }}".</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-responsive">
|
||||||
|
<table class="table table-hover border-bottom">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="small">#ID</th>
|
||||||
|
<th scope="col" class="small">Categoria</th>
|
||||||
|
<th scope="col" class="small">Indirizzo</th>
|
||||||
|
<th scope="col" class="small">Stato</th>
|
||||||
|
<th scope="col" class="small">Creazione</th>
|
||||||
|
<th scope="col" class="text-center small">Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="report in paginatedReports" :key="report.id" class="align-middle">
|
||||||
|
<td class="small font-weight-semibold">#{{ report.id }}</td>
|
||||||
|
<td class="small">{{ report.category_name }}</td>
|
||||||
|
<td class="small">
|
||||||
|
<span class="text-muted text-truncate" style="max-width: 200px; display: inline-block;">
|
||||||
|
{{ report.address }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
<span class="badge" :class="`bg-${getStatusColor(report.status)}`">
|
||||||
|
{{ getStatusLabel(report.status) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">{{ formatDate(report.created_at) }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<NuxtLink :to="`/reports/${report.id}`" class="btn btn-sm btn-outline-primary p-1">
|
||||||
|
<BIIcon name="it-arrow-right" size="xs" />
|
||||||
|
</NuxtLink>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paginazione Client-Side (Opzionale: implementare se hai molti dati) -->
|
||||||
|
<div v-if="totalFiltered > perPage" class="d-flex justify-content-center mt-4">
|
||||||
|
<!-- Qui potresti inserire un componente Pagination se ne hai uno -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.display-6 { font-size: 2.2rem; line-height: 1; }
|
||||||
|
.table-hover tbody tr:hover { background-color: rgba(0, 111, 157, 0.03); }
|
||||||
|
.btn-group .btn { min-width: 80px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<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">
|
||||||
|
<!-- Colonna Sinistra: Segnalazioni da prendere in carico -->
|
||||||
|
<div class="col-12 col-lg-6 mb-4">
|
||||||
|
<h3 class="mb-3 border-bottom pb-2">Da prendere in carico (OPEN)</h3>
|
||||||
|
<div class="list-group">
|
||||||
|
<div v-if="openReportsRes?.ok && openReportsRes.data?.items.length === 0" class="text-muted">
|
||||||
|
Nessuna nuova segnalazione in ingresso.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="report in openReportsRes?.data?.items" :key="report.id"
|
||||||
|
class="list-group-item d-flex justify-content-between align-items-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1">{{ report.address }}</h6>
|
||||||
|
<small class="text-muted">{{ report.created_at }}</small>
|
||||||
|
</div>
|
||||||
|
<button @click="handleTakeReport(report.id)" class="btn btn-outline-primary btn-sm">
|
||||||
|
Prendi in carico
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colonna Destra: Segnalazioni in gestione dell'operatore -->
|
||||||
|
<div class="col-12 col-lg-6 mb-4">
|
||||||
|
<h3 class="mb-3 border-bottom pb-2">Le mie assegnazioni</h3>
|
||||||
|
<div class="list-group">
|
||||||
|
<div v-if="assignedReportsRes?.ok && assignedReportsRes.data?.items.length === 0" class="text-muted">
|
||||||
|
Non hai segnalazioni in carico.
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
v-for="report in assignedReportsRes?.data?.items" :key="report.id"
|
||||||
|
:to="`/operator/reports/${report.id}`"
|
||||||
|
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1">{{ report.address }}</h6>
|
||||||
|
<span class="badge bg-warning text-dark">{{ report.status }}</span>
|
||||||
|
</div>
|
||||||
|
<BIIcon name="it-chevron-right" size="sm" color="primary" />
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -2,14 +2,48 @@ import { defu } from 'defu'
|
|||||||
|
|
||||||
export const useApiFetch = <T>(request: string, options: Parameters<typeof $fetch>[1] = {}) => {
|
export const useApiFetch = <T>(request: string, options: Parameters<typeof $fetch>[1] = {}) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const token = useCookie('auth_token') // Make sure this matches the cookie name you use on login
|
const tokenCookie = useCookie('auth_token')
|
||||||
|
const tokenState = useState<string | null>('auth_token_state')
|
||||||
|
|
||||||
|
const tokenValue = tokenState.value ?? tokenCookie.value
|
||||||
|
|
||||||
const defaults: Parameters<typeof $fetch>[1] = {
|
const defaults: Parameters<typeof $fetch>[1] = {
|
||||||
baseURL: config.public.api as string,
|
baseURL: config.public.api as string,
|
||||||
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {},
|
headers: tokenValue ? { Authorization: `Bearer ${tokenValue}` } : {},
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = defu(options, defaults)
|
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) {
|
||||||
|
try {
|
||||||
|
tokenCookie.value = null
|
||||||
|
tokenState.value = null
|
||||||
|
const authUser = useState('auth_user')
|
||||||
|
authUser.value = null
|
||||||
|
|
||||||
|
console.error(`[API] Authentication error (${status}) - token cleared`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[API] Error clearing auth state:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
navigateTo('/auth/login')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[API] Error navigating to login:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,55 @@ import type {
|
|||||||
} from "~/types/api";
|
} from "~/types/api";
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
|
const token = useCookie('auth_token', {
|
||||||
|
maxAge: 60 * 60 * 12,
|
||||||
|
sameSite: 'lax',
|
||||||
|
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))
|
||||||
|
|
||||||
|
watch(() => token.value, (newVal) => {
|
||||||
|
tokenState.value = newVal || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncUser = async () => {
|
||||||
|
const tokenVal = tokenState.value || token.value
|
||||||
|
if (!tokenVal || isSyncingUser.value) return
|
||||||
|
|
||||||
|
isSyncingUser.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetchCurrentUser()
|
||||||
|
if (res.ok) {
|
||||||
|
console.log('[Auth] User synced successfully')
|
||||||
|
user.value = res.user
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
token.value = null
|
||||||
|
tokenState.value = null
|
||||||
|
user.value = null
|
||||||
|
navigateTo('/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
const register = (body: RegisterCitizenRequest) =>
|
const register = (body: RegisterCitizenRequest) =>
|
||||||
useApiFetch<RegisterCitizenResponse>('/api/v1/auth/register', { method: 'POST', body })
|
useApiFetch<RegisterCitizenResponse>('/api/v1/auth/register', { method: 'POST', body })
|
||||||
|
|
||||||
@@ -36,6 +85,15 @@ export const useAuth = () => {
|
|||||||
useApiFetch<OkResponse>(`/api/v1/notifications/${id}/read`, { method: 'PATCH' })
|
useApiFetch<OkResponse>(`/api/v1/notifications/${id}/read`, { method: 'PATCH' })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
tokenState,
|
||||||
|
isSyncingUser,
|
||||||
|
isAuthenticated,
|
||||||
|
handleLoginSuccess,
|
||||||
|
syncUser,
|
||||||
|
logout,
|
||||||
|
|
||||||
register,
|
register,
|
||||||
requestOtp,
|
requestOtp,
|
||||||
verifyOtp,
|
verifyOtp,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
} 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
|
||||||
|
|
||||||
|
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('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(async () => {
|
||||||
|
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()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Guest Middleware] Sync failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se autenticato, redirige al dashboard
|
||||||
|
if (auth.isAuthenticated.value && auth.user.value) {
|
||||||
|
return navigateTo('/dashboard')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<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('')
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
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)
|
||||||
|
router.push('/dashboard')
|
||||||
|
} 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)
|
||||||
|
router.push('/dashboard')
|
||||||
|
} 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">
|
||||||
|
|
||||||
|
<!-- Intestazione istituzionale -->
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<BIIcon name="it-pa" 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">
|
||||||
|
|
||||||
|
<!-- Finto Bottone CIE -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- FLUSSO OTP -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- FLUSSO PASSWORD -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- INPUT PASSWORD FIXATO -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Switch Method -->
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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 computeSurnameCode = (surname = '') => {
|
||||||
|
const s = surname.toUpperCase().replace(/[^A-Z]/g, '')
|
||||||
|
const consonants = s.replace(/[AEIOU]/g, '')
|
||||||
|
const vowels = s.replace(/[^AEIOU]/g, '')
|
||||||
|
return (consonants + vowels + 'XXX').slice(0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const computeNameCode = (name = '') => {
|
||||||
|
const s = name.toUpperCase().replace(/[^A-Z]/g, '')
|
||||||
|
const consonants = s.replace(/[AEIOU]/g, '')
|
||||||
|
const vowels = s.replace(/[^AEIOU]/g, '')
|
||||||
|
if (consonants.length >= 4) {
|
||||||
|
return consonants.charAt(0) + consonants.charAt(2) + consonants.charAt(3)
|
||||||
|
}
|
||||||
|
return (consonants + vowels + 'XXX').slice(0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!form.value.privacy) {
|
||||||
|
errorMessage.value = "È necessario accettare l'informativa sulla privacy."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = computeSurnameCode(form.value.last_name) + computeNameCode(form.value.first_name)
|
||||||
|
if (form.value.fiscal_code.trim().length >= 6) {
|
||||||
|
const provided = form.value.fiscal_code.trim().toUpperCase().slice(0, 6)
|
||||||
|
if (provided !== expected) {
|
||||||
|
errorMessage.value = `Il Codice Fiscale non sembra corrispondere a nome/cognome. Atteso: ${expected}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
first_name: form.value.first_name,
|
||||||
|
last_name: form.value.last_name,
|
||||||
|
fiscal_code: form.value.fiscal_code.toUpperCase(),
|
||||||
|
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>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<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'
|
||||||
|
|
||||||
|
// Normalizziamo in uppercase nel caso il backend risponda con 'citizen' o 'CITIZEN'
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
// Testi dinamici per l'Hero (la fascia blu in alto) in base al ruolo
|
||||||
|
const heroTitle = computed(() => {
|
||||||
|
const role = user.value?.role?.toUpperCase()
|
||||||
|
if (role === 'ADMIN') return 'Pannello di Amministrazione'
|
||||||
|
if (role === 'OPERATOR') return 'Gestione Operativa'
|
||||||
|
return 'Le Mie Segnalazioni' // Cittadino
|
||||||
|
})
|
||||||
|
|
||||||
|
const heroSubtitle = computed(() => {
|
||||||
|
const role = user.value?.role?.toUpperCase()
|
||||||
|
if (role === 'ADMIN') return 'Gestione del sistema, delle aree e moderazione utenti'
|
||||||
|
if (role === 'OPERATOR') return 'Prendi in carico e gestisci le segnalazioni in corso'
|
||||||
|
return 'Traccia lo stato di tutte le tue segnalazioni e comunica con gli operatori' // Cittadino
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="it-hero-wrapper it-primary">
|
||||||
|
<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">{{ 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-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h4 class="mb-2 text-muted">
|
||||||
|
Benvenuto, <strong>{{ user?.first_name }} {{ user?.last_name }}</strong>.
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Qui viene montato CitizenDashboard.vue, OperatorDashboard.vue o AdminDashboard.vue -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Il gradiente originale della pagina rotta */
|
||||||
|
.it-primary {
|
||||||
|
background: linear-gradient(135deg, #006f9d 0%, #003d5c 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Il Mio Profilo - Visione' })
|
||||||
|
|
||||||
|
const { user, fetchRewards } = useAuth()
|
||||||
|
|
||||||
|
// Fetch rewards/punti
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
|
||||||
|
const { data: rewardsData } = await useAsyncData(() =>
|
||||||
|
fetchRewards({ page: page.value, per_page: perPage.value })
|
||||||
|
)
|
||||||
|
|
||||||
|
const rewards = computed(() => (rewardsData.value as any)?.reward_events || [])
|
||||||
|
const totalPoints = computed(() => (rewardsData.value as any)?.points || 0)
|
||||||
|
const totalRewards = computed(() => rewards.value.length)
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
page.value = newPage
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return new Date(dateString).toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
useAuth().logout()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- HERO -->
|
||||||
|
<div class="bg-light border-bottom py-4 mb-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="h3 font-weight-bold mb-3">Il Mio Profilo</h1>
|
||||||
|
<p class="text-muted">Gestisci i tuoi dati, visualizza i tuoi punti e la cronologia delle segnalazioni</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO -->
|
||||||
|
<div class="container mb-5">
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<!-- SINISTRA: Profilo -->
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
|
||||||
|
<!-- DATI PERSONALI -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h5 class="card-title font-weight-bold mb-0">
|
||||||
|
<BIIcon name="it-user" size="sm" color="primary" class="me-2" />
|
||||||
|
Dati Personali
|
||||||
|
</h5>
|
||||||
|
<NuxtLink to="/settings/profile" class="btn btn-sm btn-outline-primary">
|
||||||
|
<BIIcon name="it-pencil" size="xs" class="me-1" />
|
||||||
|
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">Codice Fiscale</small>
|
||||||
|
<strong>{{ user?.fiscal_code }}</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-3">
|
||||||
|
<small class="text-muted d-block">Ruolo</small>
|
||||||
|
<span class="badge bg-info">{{ user?.role }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PUNTI CITTADINO -->
|
||||||
|
<div class="card card-bg card-highlight mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title font-weight-bold mb-4">
|
||||||
|
<BIIcon name="it-star" size="sm" color="warning" class="me-2" />
|
||||||
|
I Tuoi Punti Cittadino
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div class="text-center p-4 bg-light rounded mb-4">
|
||||||
|
<div class="display-3 font-weight-bold text-primary">{{ totalPoints }}</div>
|
||||||
|
<small class="text-muted">Segnalazioni risolte completate con successo</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<small>
|
||||||
|
<strong>Come guadagni punti?</strong><br>
|
||||||
|
Ricevi 1 punto per ogni segnalazione che risolviamo. Quando raggiungi determinati traguardi, potrai sbloccare riconoscimenti speciali!
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CRONOLOGIA PUNTI -->
|
||||||
|
<div class="card card-bg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title font-weight-bold mb-4">
|
||||||
|
<BIIcon name="it-history" size="sm" color="primary" class="me-2" />
|
||||||
|
Cronologia Punti (ultimi {{ rewards.length }})
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<div v-if="rewards.length === 0" class="alert alert-info" role="alert">
|
||||||
|
Non hai ancora accumulato punti. Crea la tua prima segnalazione!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="small">Data</th>
|
||||||
|
<th scope="col" class="small">Segnalazione</th>
|
||||||
|
<th scope="col" class="text-center small">Punti</th>
|
||||||
|
<th scope="col" class="small">Motivo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="reward in rewards" :key="reward.id">
|
||||||
|
<td class="small">
|
||||||
|
<small class="text-muted">{{ formatDate(reward.created_at) }}</small>
|
||||||
|
</td>
|
||||||
|
<td class="small">
|
||||||
|
<NuxtLink :to="`/reports/${reward.report_id}`" class="text-decoration-none">
|
||||||
|
#{{ reward.report_id }}
|
||||||
|
</NuxtLink>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge bg-success">+{{ reward.points }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ reward.reason }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PAGINATION -->
|
||||||
|
<Pagination
|
||||||
|
v-if="rewards.length > 0"
|
||||||
|
:current-page="page"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-items="totalRewards"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESTRA: Azioni rapide -->
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
|
||||||
|
<!-- NUOVA SEGNALAZIONE -->
|
||||||
|
<div class="card card-bg card-highlight mb-4">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<BIIcon name="it-plus-circle" size="xl" color="primary" class="mb-3" />
|
||||||
|
<h6 class="font-weight-bold mb-3">Crea una nuova segnalazione</h6>
|
||||||
|
<p class="small text-muted mb-3">Aiutaci a migliorare il tuo quartiere</p>
|
||||||
|
<NuxtLink to="/reports/create" class="btn btn-primary w-100">
|
||||||
|
Segnala ora
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LE MIE SEGNALAZIONI -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-list" size="sm" color="primary" class="me-2" />
|
||||||
|
Le Mie Segnalazioni
|
||||||
|
</h6>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-primary w-100 btn-sm">
|
||||||
|
Vai alla Dashboard
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NOTIFICHE -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-bell" size="sm" color="primary" class="me-2" />
|
||||||
|
Centro Notifiche
|
||||||
|
</h6>
|
||||||
|
<NuxtLink to="/notifications" class="btn btn-outline-primary w-100 btn-sm">
|
||||||
|
Visualizza notifiche
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IMPOSTAZIONI & LOGOUT -->
|
||||||
|
<div class="card card-bg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-settings" size="sm" color="primary" class="me-2" />
|
||||||
|
Account
|
||||||
|
</h6>
|
||||||
|
<NuxtLink to="/settings/password" class="btn btn-outline-secondary w-100 btn-sm mb-2">
|
||||||
|
Cambia Password
|
||||||
|
</NuxtLink>
|
||||||
|
<button type="button" class="btn btn-outline-danger w-100 btn-sm" @click="handleLogout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-highlight {
|
||||||
|
border-color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-3 {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: rgba(0, 111, 157, 0.05);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
|
||||||
|
// 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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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">
|
||||||
|
<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 openDataResponse.reports" :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 (Placeholder for Leaflet/Mapbox) -->
|
||||||
|
<div v-else-if="currentView === 'map'" class="map-view-wrapper">
|
||||||
|
<div class="alert alert-info d-flex align-items-center" role="alert">
|
||||||
|
<span>La visualizzazione mappa è un modulo interattivo (ad es. Leaflet o Mapbox) che utilizzerà le coordinate <strong>latitudine</strong> e <strong>longitudine</strong> fornite dalle API.</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-light border rounded d-flex align-items-center justify-content-center" style="height: 500px;">
|
||||||
|
<div class="text-center text-muted">
|
||||||
|
<BIIcon name="it-map" size="xl" class="mb-3 opacity-50" />
|
||||||
|
<h4 class="h5">Mappa Interattiva</h4>
|
||||||
|
<p>Caricamento dei {{ openDataResponse?.reports?.length || 0 }} punti geografici in corso...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Dettaglio Segnalazione - Visione' })
|
||||||
|
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { getReportDetail, getMessages, addMessage, addFeedback, flagReport } = 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 flagMessage = ref('')
|
||||||
|
const isFlagging = ref(false)
|
||||||
|
const selectedMediaIndex = ref(0)
|
||||||
|
const isGalleryOpen = ref(false)
|
||||||
|
|
||||||
|
// Status badge color
|
||||||
|
const getStatusColor = (status: string): string => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
OPEN: 'primary',
|
||||||
|
UNDER_REVIEW: 'info',
|
||||||
|
ASSIGNED: 'warning',
|
||||||
|
IN_PROGRESS: 'warning',
|
||||||
|
RESOLVED: 'success',
|
||||||
|
REJECTED: 'danger',
|
||||||
|
CLOSED: 'secondary',
|
||||||
|
}
|
||||||
|
return colors[status] || 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status label italiano
|
||||||
|
const getStatusLabel = (status: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
OPEN: 'Aperta',
|
||||||
|
UNDER_REVIEW: 'In revisione',
|
||||||
|
ASSIGNED: 'Assegnata',
|
||||||
|
IN_PROGRESS: 'In corso',
|
||||||
|
RESOLVED: 'Risolta',
|
||||||
|
REJECTED: 'Rifiutata',
|
||||||
|
CLOSED: 'Chiusa',
|
||||||
|
}
|
||||||
|
return labels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invia messaggio
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
if (!newMessage.value.trim()) return
|
||||||
|
|
||||||
|
isSubmittingMessage.value = true
|
||||||
|
try {
|
||||||
|
await addMessage(reportId, { body: newMessage.value })
|
||||||
|
newMessage.value = ''
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segnala abuso
|
||||||
|
const handleFlag = async () => {
|
||||||
|
if (!flagMessage.value.trim()) return
|
||||||
|
|
||||||
|
isFlagging.value = true
|
||||||
|
try {
|
||||||
|
await flagReport(reportId, {
|
||||||
|
reason: flagMessage.value,
|
||||||
|
})
|
||||||
|
flagMessage.value = ''
|
||||||
|
// Mostra messaggio di successo
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error flagging report:', error)
|
||||||
|
} finally {
|
||||||
|
isFlagging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = computed(() => report.value?.user_id === user.value?.id)
|
||||||
|
const canFeedback = computed(() => report.value?.status === 'RESOLVED' || report.value?.status === 'CLOSED')
|
||||||
|
|
||||||
|
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">
|
||||||
|
<!-- HEADER -->
|
||||||
|
<div class="bg-light border-bottom py-4">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-md-8">
|
||||||
|
<h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span class="badge" :class="`bg-${getStatusColor(report.status)}`">
|
||||||
|
{{ getStatusLabel(report.status) }}
|
||||||
|
</span>
|
||||||
|
<small class="text-muted">
|
||||||
|
<BIIcon name="it-clock" size="xs" class="me-1" />
|
||||||
|
Creata il {{ formatDate(report.created_at) }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO PRINCIPALE -->
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<!-- SINISTRA: Dettagli segnalazione -->
|
||||||
|
<div class="col-12 col-lg-8 mb-4">
|
||||||
|
|
||||||
|
<!-- INDIRIZZO E COORDINATE -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
|
||||||
|
Posizione
|
||||||
|
</h6>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>{{ report.address }}</strong>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
Coordinate: {{ report.latitude }}, {{ report.longitude }}
|
||||||
|
</small>
|
||||||
|
<small v-if="report.area_name" class="text-muted d-block">
|
||||||
|
Area: {{ report.area_name }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESCRIZIONE -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-write" size="sm" color="primary" class="me-2" />
|
||||||
|
Descrizione
|
||||||
|
</h6>
|
||||||
|
<p class="card-text text-break">{{ report.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GALLERY MEDIA -->
|
||||||
|
<div v-if="media.length > 0" class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
|
||||||
|
Allegati ({{ media.length }})
|
||||||
|
</h6>
|
||||||
|
<div class="row">
|
||||||
|
<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">
|
||||||
|
<img :src="runtimeConfig.public.api + m.url" :alt="`Allegato ${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">
|
||||||
|
<BIIcon name="it-video" size="xl" color="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CRONOLOGIA STATI -->
|
||||||
|
<div v-if="statusHistory.length > 0" class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-4">
|
||||||
|
<BIIcon name="it-history" size="sm" color="primary" class="me-2" />
|
||||||
|
Cronologia
|
||||||
|
</h6>
|
||||||
|
<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">
|
||||||
|
{{ getStatusLabel(item.old_status) }} → {{ getStatusLabel(item.new_status) }}
|
||||||
|
</div>
|
||||||
|
<small v-if="item.note" class="text-muted d-block">{{ item.note }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MESSAGGI -->
|
||||||
|
<div class="card card-bg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-comment" size="sm" color="primary" class="me-2" />
|
||||||
|
Chat ({{ messages.length }})
|
||||||
|
</h6>
|
||||||
|
<MessageList :messages="messages" />
|
||||||
|
<MessageComposer @send="(msg) => { newMessage = msg; handleSendMessage() }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DESTRA: Sidebar azioni -->
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
|
||||||
|
<!-- FEEDBACK (se risolta) -->
|
||||||
|
<div v-if="canFeedback && isOwner" class="card card-bg card-highlight mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-star-outline" size="sm" color="primary" class="me-2" />
|
||||||
|
Lascia un feedback
|
||||||
|
</h6>
|
||||||
|
<button
|
||||||
|
v-if="!isFeedbackOpen"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary w-100"
|
||||||
|
@click="isFeedbackOpen = true"
|
||||||
|
>
|
||||||
|
Valuta l'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' : '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-light" @click="isFeedbackOpen = false">
|
||||||
|
Cancella
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SEGNALA ABUSO -->
|
||||||
|
<div class="card card-bg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-flag" size="sm" color="danger" class="me-2" />
|
||||||
|
Segnala abuso
|
||||||
|
</h6>
|
||||||
|
<textarea
|
||||||
|
v-model="flagMessage"
|
||||||
|
class="form-control form-control-sm mb-2"
|
||||||
|
placeholder="Specifica il motivo dell'abuso..."
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-danger w-100"
|
||||||
|
@click="handleFlag"
|
||||||
|
:disabled="isFlagging || !flagMessage.trim()"
|
||||||
|
>
|
||||||
|
<BIIcon name="it-flag" size="xs" class="me-1" />
|
||||||
|
Invia segnalazione
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LOADING STATE -->
|
||||||
|
<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>
|
||||||
|
.card-highlight {
|
||||||
|
border-color: #006f9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CreateReportRequest } from '~/types/api'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Crea Segnalazione - Visione' })
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { createReport, uploadMedia } = useReports()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
// Fetch categories
|
||||||
|
const { data: categoriesData } = await useAsyncData('categories', () =>
|
||||||
|
$fetch('/api/v1/admin/categories')
|
||||||
|
)
|
||||||
|
const categories = computed(() => (categoriesData.value as any)?.categories || [])
|
||||||
|
|
||||||
|
// 👇 Manteniamo una sola dichiarazione per queste variabili
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
// 👇 Manteniamo una sola funzione handleReportSubmit
|
||||||
|
const handleReportSubmit = async (reportData: CreateReportRequest, mediaFiles: File[]) => {
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Carica i file media se presenti
|
||||||
|
let uploadedMediaUrls: string[] = []
|
||||||
|
|
||||||
|
if (mediaFiles.length > 0) {
|
||||||
|
console.log('📸 Media files to upload:', mediaFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Crea la segnalazione
|
||||||
|
const response = await createReport({
|
||||||
|
...reportData,
|
||||||
|
media: uploadedMediaUrls, // Se l'API richiede stringhe pre-uploadate, va bene. Altrimenti passa array vuoto.
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Step 3: Se ha media, carica dopo la creazione usando l'ID
|
||||||
|
if (mediaFiles.length > 0 && response.report_id) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
mediaFiles.forEach((file) => {
|
||||||
|
formData.append('files', file)
|
||||||
|
})
|
||||||
|
|
||||||
|
await uploadMedia(response.report_id, formData)
|
||||||
|
console.log('✅ Media uploaded successfully')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('⚠️ Media upload failed (non-critical):', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Segnalazione creata:', response.report_id)
|
||||||
|
|
||||||
|
// Redirect al dettaglio della segnalazione
|
||||||
|
await router.push(`/reports/${response.report_id}`)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage.value = error.data?.message || 'Errore durante la creazione della segnalazione. Riprova.'
|
||||||
|
console.error('❌ Error creating report:', error)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- HERO -->
|
||||||
|
<div class="it-hero-wrapper it-primary-bg">
|
||||||
|
<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">Segnala un problema</h1>
|
||||||
|
<p class="text-white mt-2 lead">
|
||||||
|
Aiutaci a migliorare la città condividendo i disservizi che noti
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO -->
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-8 offset-lg-2">
|
||||||
|
|
||||||
|
<!-- CALLOUT INFO -->
|
||||||
|
<div class="callout callout-highlight callout-primary mb-4">
|
||||||
|
<div class="callout-title">
|
||||||
|
<BIIcon name="it-info-circle" />
|
||||||
|
La tua segnalazione è anonima
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
I tuoi dati personali non saranno visibili pubblicamente. La segnalazione verrà elaborata dagli operatori comunali e potrai tracciare lo stato dell'intervento in tempo reale.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ERRORE GLOBALE -->
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
<button type="button" class="btn-close" @click="errorMessage = ''"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- INFO UTENTE -->
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title font-weight-bold mb-3">
|
||||||
|
<BIIcon name="it-user" size="sm" color="primary" class="me-2" />
|
||||||
|
Stai segnalando come:
|
||||||
|
</h6>
|
||||||
|
<div class="small">
|
||||||
|
<strong>{{ user?.first_name }} {{ user?.last_name }}</strong><br>
|
||||||
|
<span class="text-muted">{{ user?.email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FORM PRINCIPALE -->
|
||||||
|
<ReportForm :categories="categories" :is-loading="isLoading" @submit="handleReportSubmit" />
|
||||||
|
|
||||||
|
<!-- NOTE LEGALI -->
|
||||||
|
<div class="alert alert-info mt-5" role="alert">
|
||||||
|
<small class="d-block">
|
||||||
|
Sottoponendo questa segnalazione accetti i nostri <NuxtLink to="/privacy" target="_blank">Termini di Servizio</NuxtLink>
|
||||||
|
e l'<NuxtLink to="/privacy" target="_blank">Informativa sulla Privacy</NuxtLink>.
|
||||||
|
Le segnalazioni con contenuti abusivi o offensivi verranno rimosse e l'utente potrebbe essere sanzionato.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.it-primary-bg {
|
||||||
|
background: linear-gradient(135deg, #006f9d 0%, #003d5c 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export default defineNuxtPlugin(async () => {
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
if ((auth.token.value || auth.tokenState.value) && !auth.user.value) {
|
||||||
|
try {
|
||||||
|
await auth.syncUser()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth Plugin] Sync failed:', error)
|
||||||
|
auth.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Auth Plugin] Initialized - User:', auth.user.value?.id || 'None', 'Authenticated:', auth.isAuthenticated.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export default defineNuxtPlugin(async (nuxtApp) => {
|
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||||
|
// @ts-expect-error
|
||||||
await import('bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js')
|
await import('bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js')
|
||||||
})
|
})
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
declare module '#app' {
|
||||||
|
interface PageMeta {
|
||||||
|
auth?: {
|
||||||
|
role?: 'CITIZEN' | 'OPERATOR' | 'ADMIN'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {}
|
||||||
@@ -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
|
||||||
@@ -21,6 +21,29 @@ export default defineNuxtConfig({
|
|||||||
name: 'Visione - Un\'occhio per la sicurezza del cittadino.',
|
name: 'Visione - Un\'occhio per la sicurezza del cittadino.',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
devProxy: {
|
||||||
|
'/api/v1': {
|
||||||
|
target: 'https://bk.visione.studentindipendenti.it/api/v1',
|
||||||
|
changeOrigin: true,
|
||||||
|
headers: {
|
||||||
|
host: 'bk.visione.studentindipendenti.it',
|
||||||
|
origin: 'https://visione.studentindipendenti.it',
|
||||||
|
referer: 'https://visione.studentindipendenti.it/'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: 'https://bk.visione.studentindipendenti.it/uploads',
|
||||||
|
changeOrigin: true,
|
||||||
|
headers: {
|
||||||
|
host: 'bk.visione.studentindipendenti.it',
|
||||||
|
origin: 'https://visione.studentindipendenti.it',
|
||||||
|
referer: 'https://visione.studentindipendenti.it/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
css: [
|
css: [
|
||||||
'@/assets/css/style.css',
|
'@/assets/css/style.css',
|
||||||
'bootstrap-italia/dist/css/bootstrap-italia.min.css'
|
'bootstrap-italia/dist/css/bootstrap-italia.min.css'
|
||||||
|
|||||||
Reference in New Issue
Block a user