7 Commits
  • chore: clean up redundant icons in legal and open data pages
    - Remove redundant BIIcon declarations in open-data.vue and privacy.vue
  • feat: implement citizen report creation, detail view, and chat system
    - Create report creation page with step-by-step media uploads and categories loading
    - Create report details view page with history events log and messaging section
    - Implement MessageComposer and MessageList components for chat workflow
    - Implement MediaUploader with drag-and-drop support, file type, and size validation
    - Implement Pagination component with dynamic buttons and ellipsis support
    - Implement user profile settings summary page and notifications listing page
  • feat: implement dynamic dashboard routing with role-specific views
    - Create unified dashboard page that renders dashboard view based on user role
    - Implement CitizenDashboard with local filtering, pagination, and user rewards points visualization
    - Implement OperatorDashboard with list of open/assigned reports and assignment actions
    - Implement AdminDashboard with admin statistics overview card components and management links
  • refactor: integrate auth state and action buttons in Header
    - Use useAuth composable in Header to query user profile details and handle logout action
    - Conditionally render User Profile or login/logout action buttons based on auth state
    - Update design elements to use BIIcon component
    - Fix minor typo in brand tagline text
  • refactor: improve auth login and register pages with UX improvements
    - Add guest middleware to login page
    - Integrate auth handles for handleLoginSuccess on verifyOtp and passwordLogin
    - Add password visibility toggle to register and login password inputs
    - Calculate and validate Italian Fiscal Code structure against name/surname input on register
    - Align form-group structure within row cols for Bootstrap Italia layout compliance
  • feat: implement auth middleware, user sync, and token state persistence
    - Define useAuth composable logic with syncUser, handleLoginSuccess, and logout methods
    - Add auth.ts and guest.ts route middlewares to guard routes based on authentication and user roles
    - Add auth.client.ts plugin to boot-sync logged in user details
    - Declare custom PageMeta type definitions to support role-based authorization rules
  • feat: configure API dev proxy and improve useApiFetch error handling
    - Add Nitro devProxy for local proxying of api/v1 and uploads to the backend server
    - Improve useApiFetch to dynamically clear auth state and navigate to login on 401/403 errors
    - Automatically strip Content-Type headers for multipart FormData upload request bodies
    - Add css rule to fix Bootstrap Italia navbar link border display
    - Add ts-expect-error tag to avoid type checking issues with bootstrap-italia bundle loading
27 changed files with 2601 additions and 65 deletions
+4
View File
@@ -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
}
+35 -16
View File
@@ -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>
@@ -29,36 +28,56 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
<div class="col-12"> <div class="col-12">
<div class="it-header-slim-wrapper-content"> <div class="it-header-slim-wrapper-content">
<a class="d-none d-lg-block navbar-brand" <a class="d-none d-lg-block navbar-brand"
href="https://www.interno.gov.it/it/ministero/dipartimenti/dipartimento-pubblica-sicurezza">Dipartimento href="https://www.interno.gov.it/it/ministero/dipartimenti/dipartimento-pubblica-sicurezza">
della Pubblica Sicurezza</a> 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" <a class="it-opener d-lg-none" data-bs-toggle="collapse"
href="https://www.interno.gov.it/it/ministero/dipartimenti/dipartimento-pubblica-sicurezza" href="https://www.interno.gov.it/it/ministero/dipartimenti/dipartimento-pubblica-sicurezza"
role="button" aria-expanded="false"> role="button" aria-expanded="false">
<span>Dipartmento della Pubblica Sicureazza</span> <span>Dipartimento della Pubblica Sicurezza</span>
<BIIcon name="it-expand"/> <BIIcon name="it-expand"/>
</a> </a>
</nav> </nav>
</div> </div>
<div class="header-slim-right-zone" role="navigation"> <div class="header-slim-right-zone" role="navigation">
<a class="btn btn-primary btn-icon btn-full" href="/auth/login" <!-- Utente Autenticato: Bottone Utente + Bottone Esci -->
<div v-if="isAuthenticated" class="d-flex align-items-center gap-2">
<NuxtLink class="btn btn-primary btn-icon btn-full" to="/dashboard"
data-element="personal-area-dashboard" data-focus-mouse="false">
<span class="rounded-icon" aria-hidden="true">
<BIIcon name="it-user" />
</span>
<span class="d-none d-lg-block">{{ user?.first_name }}</span>
</NuxtLink>
<button class="btn btn-primary btn-icon btn-full border-0" @click="logout"
data-element="personal-area-logout" data-focus-mouse="false">
<span class="rounded-icon" aria-hidden="true">
<BIIcon name="it-logout" /> <!-- 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"> data-element="personal-area-login" data-focus-mouse="false">
<span class="rounded-icon" aria-hidden="true"> <span class="rounded-icon" aria-hidden="true">
<svg class="custom-rounded-icon icon-primary"> <BIIcon name="it-user" />
<use
xlink:href="https://www.cartaidentita.interno.gov.it/./wp-content/themes/design-italia/lib/bootstrap-italia/svg/sprite.svg#it-user"></use>
</svg>
</span> </span>
<span class="d-none d-lg-block">Login</span> <span class="d-none d-lg-block">Login</span>
</a></div> </NuxtLink>
</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">
@@ -70,7 +89,7 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
<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>
@@ -153,8 +172,8 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
background-color: rgb(0, 76.5, 153); 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 a),
:deep(.it-header-slim-wrapper .it-header-slim-wrapper-content .header-slim-right-zone button) {
background-color: rgb(0, 76.5, 153); background-color: rgb(0, 76.5, 153);
} }
+163
View File
@@ -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>
+83
View File
@@ -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>
+106
View File
@@ -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>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
interface Props {
currentPage: number
perPage: number
totalItems: number
}
interface Emits {
(e: 'page-change', page: number): void
}
const props = withDefaults(defineProps<Props>(), {
currentPage: 1,
perPage: 10,
totalItems: 0
})
const emit = defineEmits<Emits>()
const totalPages = computed(() => Math.ceil(props.totalItems / props.perPage))
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
emit('page-change', page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
const pageNumbers = computed(() => {
const pages: (number | string)[] = []
const maxVisible = 5
if (totalPages.value <= maxVisible) {
for (let i = 1; i <= totalPages.value; i++) pages.push(i)
} else {
if (props.currentPage <= 3) {
for (let i = 1; i <= maxVisible - 1; i++) pages.push(i)
pages.push('...')
pages.push(totalPages.value)
} else if (props.currentPage >= totalPages.value - 2) {
pages.push(1)
pages.push('...')
for (let i = totalPages.value - (maxVisible - 2); i <= totalPages.value; i++) pages.push(i)
} else {
pages.push(1)
pages.push('...')
for (let i = props.currentPage - 1; i <= props.currentPage + 1; i++) pages.push(i)
pages.push('...')
pages.push(totalPages.value)
}
}
return pages
})
</script>
<template>
<nav v-if="totalPages > 1" class="d-flex justify-content-center mt-5 mb-5">
<ul class="pagination mb-0">
<!-- Pagina Precedente -->
<li class="page-item" :class="{ disabled: currentPage === 1 }">
<button
class="page-link"
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
aria-label="Pagina precedente"
>
<span aria-hidden="true">&laquo;</span>
</button>
</li>
<!-- Numeri Pagine -->
<li v-for="page in pageNumbers" :key="`page-${page}`" class="page-item" :class="{ active: page === currentPage }">
<template v-if="page === '...'">
<span class="page-link">...</span>
</template>
<template v-else>
<button
class="page-link"
@click="goToPage(page as number)"
:aria-label="`Vai a pagina ${page}`"
:aria-current="page === currentPage ? 'page' : undefined"
>
{{ page }}
</button>
</template>
</li>
<!-- Pagina Successiva -->
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
<button
class="page-link"
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
aria-label="Pagina successiva"
>
<span aria-hidden="true">&raquo;</span>
</button>
</li>
</ul>
</nav>
<!-- Info Pagina -->
<div v-if="totalPages > 1" class="text-center text-muted small mt-3">
Pagina {{ currentPage }} di {{ totalPages }} ({{ totalItems }} elementi totali)
</div>
</template>
<style scoped>
.pagination {
gap: 0.25rem;
}
</style>
+298
View File
@@ -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>
+37 -3
View File
@@ -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
})
} }
+58
View File
@@ -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,
+39
View File
@@ -0,0 +1,39 @@
export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuth()
// Se c'è un token ma l'utente non è ancora caricato e non stiamo già sincronizzando, sincronizza
if ((auth.token.value || auth.tokenState.value) && !auth.user.value && !auth.isSyncingUser.value) {
try {
await auth.syncUser()
} 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('/')
}
}
})
+17
View File
@@ -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')
}
})
+32 -22
View File
@@ -1,9 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
definePageMeta({
middleware: 'guest'
})
useHead({ title: 'Accedi - Visione' }) useHead({ title: 'Accedi - Visione' })
const { requestOtp, verifyOtp, loginWithPassword } = useAuth() const { requestOtp, verifyOtp, loginWithPassword, handleLoginSuccess } = useAuth()
const router = useRouter() const router = useRouter()
// UI State // UI State
@@ -16,8 +20,8 @@ const errorMessage = ref('')
const email = ref('') const email = ref('')
const password = ref('') const password = ref('')
const otpCode = ref('') const otpCode = ref('')
const showPassword = ref(false)
// Azione 1: Richiedi OTP
const handleRequestOtp = async () => { const handleRequestOtp = async () => {
isLoading.value = true isLoading.value = true
errorMessage.value = '' errorMessage.value = ''
@@ -31,16 +35,13 @@ const handleRequestOtp = async () => {
} }
} }
// Azione 2: Verifica OTP
const handleVerifyOtp = async () => { const handleVerifyOtp = async () => {
isLoading.value = true isLoading.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
const res = await verifyOtp({ email: email.value, code: otpCode.value }) const res = await verifyOtp({ email: email.value, code: otpCode.value })
// Salva il token in un cookie (gestito automaticamente da Nuxt e accessibile in useApiFetch) await handleLoginSuccess(res.token)
const tokenCookie = useCookie('auth_token', { maxAge: res.expires_in_hours * 3600 }) router.push('/dashboard')
tokenCookie.value = res.token
router.push('/dashboard') // O qualsiasi pagina post-login
} catch (err: any) { } catch (err: any) {
errorMessage.value = 'Codice OTP non valido o scaduto.' errorMessage.value = 'Codice OTP non valido o scaduto.'
} finally { } finally {
@@ -48,14 +49,12 @@ const handleVerifyOtp = async () => {
} }
} }
// Azione 3: Login con Password (Fallback)
const handlePasswordLogin = async () => { const handlePasswordLogin = async () => {
isLoading.value = true isLoading.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
const res = await loginWithPassword({ email: email.value, password: password.value }) const res = await loginWithPassword({ email: email.value, password: password.value })
const tokenCookie = useCookie('auth_token', { maxAge: res.expires_in_hours * 3600 }) await handleLoginSuccess(res.token)
tokenCookie.value = res.token
router.push('/dashboard') router.push('/dashboard')
} catch (err: any) { } catch (err: any) {
errorMessage.value = 'Credenziali non valide.' errorMessage.value = 'Credenziali non valide.'
@@ -66,7 +65,7 @@ const handlePasswordLogin = async () => {
</script> </script>
<template> <template>
<div class="d-flex align-items-center min-vh-100 bg-light py-5"> <div class="d-flex align-items-center bg-light py-5">
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-5"> <div class="col-12 col-md-8 col-lg-5">
@@ -81,7 +80,7 @@ const handlePasswordLogin = async () => {
<div class="card card-bg shadow-sm"> <div class="card card-bg shadow-sm">
<div class="card-body p-4 p-md-5"> <div class="card-body p-4 p-md-5">
<!-- Finto Bottone CIE (Stile Istituzionale) --> <!-- 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"> <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" /> <BIIcon name="it-card" size="sm" color="white" />
Entra con CIE (Presto disponibile) Entra con CIE (Presto disponibile)
@@ -89,23 +88,19 @@ const handlePasswordLogin = async () => {
<div class="d-flex align-items-center my-4"> <div class="d-flex align-items-center my-4">
<hr class="flex-grow-1"> <hr class="flex-grow-1">
<span class="mx-3 text-muted small text-uppercase">Oppure accedi con Email</span> <span class="mx-3 text-muted small text-uppercase">Oppure</span>
<hr class="flex-grow-1"> <hr class="flex-grow-1">
</div> </div>
<!-- Messaggio di errore -->
<div v-if="errorMessage" class="alert alert-danger mb-4" role="alert"> <div v-if="errorMessage" class="alert alert-danger mb-4" role="alert">
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<!-- FLUSSO OTP --> <!-- FLUSSO OTP -->
<div v-if="loginMethod === 'otp'"> <div v-if="loginMethod === 'otp'">
<!-- Step 1: Inserisci Email -->
<form v-if="otpStep === 1" @submit.prevent="handleRequestOtp"> <form v-if="otpStep === 1" @submit.prevent="handleRequestOtp">
<div class="form-group mb-4"> <div class="form-group mb-4">
<input type="email" class="form-control" id="email" v-model="email" required> <input type="email" class="form-control" id="email" v-model="email" required>
<!-- Label flottante di Bootstrap Italia (attiva se c'è testo) -->
<label for="email" :class="{ 'active': email.length > 0 }">Indirizzo Email</label> <label for="email" :class="{ 'active': email.length > 0 }">Indirizzo Email</label>
</div> </div>
<button type="submit" class="btn btn-primary w-100" :disabled="isLoading"> <button type="submit" class="btn btn-primary w-100" :disabled="isLoading">
@@ -114,7 +109,6 @@ const handlePasswordLogin = async () => {
</button> </button>
</form> </form>
<!-- Step 2: Inserisci Codice -->
<form v-else @submit.prevent="handleVerifyOtp"> <form v-else @submit.prevent="handleVerifyOtp">
<div class="alert alert-success mb-4 small"> <div class="alert alert-success mb-4 small">
Abbiamo inviato un codice OTP a <strong>{{ email }}</strong>. Abbiamo inviato un codice OTP a <strong>{{ email }}</strong>.
@@ -131,19 +125,32 @@ const handlePasswordLogin = async () => {
<a href="#" class="small text-decoration-none" @click.prevent="otpStep = 1">Modifica indirizzo email</a> <a href="#" class="small text-decoration-none" @click.prevent="otpStep = 1">Modifica indirizzo email</a>
</div> </div>
</form> </form>
</div> </div>
<!-- FLUSSO PASSWORD (Fallback) --> <!-- FLUSSO PASSWORD -->
<form v-else @submit.prevent="handlePasswordLogin"> <form v-else @submit.prevent="handlePasswordLogin">
<div class="form-group mb-4"> <div class="form-group mb-4">
<input type="email" class="form-control" id="emailPwd" v-model="email" required> <input type="email" class="form-control" id="emailPwd" v-model="email" required>
<label for="emailPwd" :class="{ 'active': email.length > 0 }">Indirizzo Email</label> <label for="emailPwd" :class="{ 'active': email.length > 0 }">Indirizzo Email</label>
</div> </div>
<div class="form-group mb-4">
<input type="password" class="form-control" id="password" v-model="password" required> <!-- 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> <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> </div>
<button type="submit" class="btn btn-primary w-100 mb-3" :disabled="isLoading"> <button type="submit" class="btn btn-primary w-100 mb-3" :disabled="isLoading">
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2" role="status"></span> <span v-if="isLoading" class="spinner-border spinner-border-sm me-2" role="status"></span>
Accedi Accedi
@@ -174,4 +181,7 @@ const handlePasswordLogin = async () => {
.text-tracking-widest { .text-tracking-widest {
letter-spacing: 0.5em; letter-spacing: 0.5em;
} }
.pe-5 {
padding-right: 3rem !important;
}
</style> </style>
+71 -13
View File
@@ -1,6 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
definePageMeta({
middleware: 'guest'
})
useHead({ title: 'Registrazione - Visione' }) useHead({ title: 'Registrazione - Visione' })
const { register } = useAuth() const { register } = useAuth()
@@ -9,8 +13,8 @@ const router = useRouter()
const isLoading = ref(false) const isLoading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const successMessage = ref(false) const successMessage = ref(false)
const showPassword = ref(false)
// Form State mappato sull'API RegisterCitizenRequest
const form = ref({ const form = ref({
first_name: '', first_name: '',
last_name: '', last_name: '',
@@ -21,12 +25,38 @@ const form = ref({
privacy: false 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 () => { const handleRegister = async () => {
if (!form.value.privacy) { if (!form.value.privacy) {
errorMessage.value = "È necessario accettare l'informativa sulla privacy." errorMessage.value = "È necessario accettare l'informativa sulla privacy."
return 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 isLoading.value = true
errorMessage.value = '' errorMessage.value = ''
@@ -41,7 +71,6 @@ const handleRegister = async () => {
}) })
successMessage.value = true successMessage.value = true
// Ritarda il redirect per far leggere il messaggio
setTimeout(() => { setTimeout(() => {
router.push('/auth/login') router.push('/auth/login')
}, 3000) }, 3000)
@@ -55,7 +84,7 @@ const handleRegister = async () => {
</script> </script>
<template> <template>
<div class="d-flex align-items-center min-vh-100 bg-light py-5"> <div class="d-flex align-items-center bg-light py-5">
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-12 col-lg-8"> <div class="col-12 col-lg-8">
@@ -70,7 +99,6 @@ const handleRegister = async () => {
<div class="card-body p-4 p-md-5"> <div class="card-body p-4 p-md-5">
<div v-if="successMessage" class="alert alert-success" role="alert"> <div v-if="successMessage" class="alert alert-success" role="alert">
<BIIcon name="it-check-circle" size="sm" class="me-2" />
Registrazione completata con successo! Reindirizzamento al login... Registrazione completata con successo! Reindirizzamento al login...
</div> </div>
@@ -81,16 +109,22 @@ const handleRegister = async () => {
<form v-if="!successMessage" @submit.prevent="handleRegister"> <form v-if="!successMessage" @submit.prevent="handleRegister">
<h2 class="h5 text-primary font-weight-bold mb-4 border-bottom pb-2">Dati Anagrafici</h2> <h2 class="h5 text-primary font-weight-bold mb-4 border-bottom pb-2">Dati Anagrafici</h2>
<div class="row">
<div class="col-12 col-md-6 form-group mb-4"> <!-- 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> <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> <label for="nome" :class="{ 'active': form.first_name.length > 0 }">Nome</label>
</div> </div>
<div class="col-12 col-md-6 form-group mb-4"> </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> <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> <label for="cognome" :class="{ 'active': form.last_name.length > 0 }">Cognome</label>
</div> </div>
</div> </div>
</div>
<div class="form-group mb-4"> <div class="form-group mb-4">
<input type="text" class="form-control text-uppercase" id="cf" v-model="form.fiscal_code" required maxlength="16"> <input type="text" class="form-control text-uppercase" id="cf" v-model="form.fiscal_code" required maxlength="16">
@@ -99,20 +133,37 @@ const handleRegister = async () => {
</div> </div>
<h2 class="h5 text-primary font-weight-bold mt-5 mb-4 border-bottom pb-2">Credenziali e Contatti</h2> <h2 class="h5 text-primary font-weight-bold mt-5 mb-4 border-bottom pb-2">Credenziali e Contatti</h2>
<div class="row">
<div class="col-12 col-md-6 form-group mb-4"> <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> <input type="email" class="form-control" id="regEmail" v-model="form.email" required>
<label for="regEmail" :class="{ 'active': form.email.length > 0 }">Indirizzo Email</label> <label for="regEmail" :class="{ 'active': form.email.length > 0 }">Indirizzo Email</label>
</div> </div>
<div class="col-12 col-md-6 form-group mb-4"> </div>
<input type="password" class="form-control" id="regPwd" v-model="form.password" required minlength="10"> <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> <label for="regPwd" :class="{ 'active': form.password.length > 0 }">Password (min. 10 crt)</label>
<button
type="button"
class="btn bg-transparent border-0 shadow-none position-absolute p-2"
style="right: 0; top: 2px; z-index: 10;"
@click="showPassword = !showPassword"
:aria-pressed="showPassword"
aria-label="Mostra/Nascondi password"
>
<BIIcon :name="showPassword ? 'it-password-visible' : 'it-password-invisible'" color="primary" size="sm" />
</button>
</div>
</div> </div>
</div> </div>
<div class="form-group mb-5"> <div class="form-group mb-5">
<input type="tel" class="form-control" id="phone" v-model="form.phone"> <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> <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> <small class="form-text text-muted">Faciliterà il contatto da parte degli operatori comunali in caso di intervento.</small>
</div> </div>
@@ -120,7 +171,7 @@ const handleRegister = async () => {
<div class="form-check mb-4"> <div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="privacyCheck" v-model="form.privacy" required> <input class="form-check-input" type="checkbox" id="privacyCheck" v-model="form.privacy" required>
<label class="form-check-label small" for="privacyCheck"> <label class="form-check-label small" for="privacyCheck">
Dichiaro di aver letto l'<NuxtLink to="/privacy" target="_blank">Informativa sulla Privacy</NuxtLink> e accetto il trattamento dei dati personali ai fini dell'erogazione del servizio. Dichiaro di aver letto l'<NuxtLink to="/privacy" target="_blank">Informativa sulla Privacy</NuxtLink> e accetto il trattamento dei dati personali.
</label> </label>
</div> </div>
@@ -142,3 +193,10 @@ const handleRegister = async () => {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
/* Previene che il testo finisca sotto l'icona */
.pe-5 {
padding-right: 3rem !important;
}
</style>
+80
View File
@@ -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>
+259
View File
@@ -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>
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
})
useHead({ title: 'Notifiche - Visione' })
const { fetchNotifications, markNotificationRead } = useAuth()
const router = useRouter()
const page = ref(1)
const perPage = ref(15)
const { data: notificationsData, refresh: refreshNotifications } = await useAsyncData(
'notifications',
() => fetchNotifications({ page: page.value, per_page: perPage.value })
)
const notifications = computed(() => notificationsData.value?.notifications || [])
const totalNotifications = computed(() => notificationsData.value?.notifications?.length || 0)
const unreadCount = computed(() => notifications.value.filter((n: any) => !n.read_at).length)
const handlePageChange = (newPage: number) => {
page.value = newPage
}
const handleMarkAsRead = async (notificationId: number) => {
try {
await markNotificationRead(notificationId)
await refreshNotifications()
} catch (error) {
console.error('Error marking notification as read:', error)
}
}
const handleNotificationClick = async (notification: any) => {
if (!notification.read_at) {
await handleMarkAsRead(notification.id)
}
if (notification.report_id) {
router.push(`/reports/${notification.report_id}`)
}
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return 'Proprio ora'
if (diffMins < 60) return `${diffMins}m fa`
if (diffHours < 24) return `${diffHours}h fa`
if (diffDays < 7) return `${diffDays}d fa`
return date.toLocaleDateString('it-IT', {
day: '2-digit',
month: '2-digit',
year: '2-digit'
})
}
const getNotificationIcon = (type: string): string => {
const icons: Record<string, string> = {
'report_created': 'it-pencil',
'report_assigned': 'it-check-circle',
'report_updated': 'it-refresh',
'report_resolved': 'it-check-circle',
'message_received': 'it-comment',
'feedback_requested': 'it-star',
'reward_earned': 'it-star',
}
return icons[type] || 'it-bell'
}
const getNotificationColor = (type: string): string => {
const colors: Record<string, string> = {
'report_created': 'primary',
'report_assigned': 'info',
'report_updated': 'info',
'report_resolved': 'success',
'message_received': 'primary',
'feedback_requested': 'warning',
'reward_earned': 'warning',
}
return colors[type] || 'secondary'
}
</script>
<template>
<div>
<!-- HERO -->
<div class="bg-light border-bottom py-4 mb-4">
<div class="container">
<div class="row">
<div class="col-12">
<h1 class="h3 font-weight-bold mb-2">
<BIIcon name="it-bell" size="sm" color="primary" class="me-2" />
Centro Notifiche
</h1>
<p class="text-muted mb-0">
{{ unreadCount > 0 ? `Hai ${unreadCount} notifiche non lette` : 'Tutte le notifiche lette' }}
</p>
</div>
</div>
</div>
</div>
<!-- CONTENUTO -->
<div class="container mb-5">
<div class="row">
<div class="col-12 col-lg-10 offset-lg-1">
<!-- FILTRI (Opzionale) -->
<div class="d-flex gap-2 mb-4">
<button class="btn btn-sm btn-outline-primary active">Tutti</button>
<button class="btn btn-sm btn-outline-secondary">Solo non letti</button>
</div>
<!-- LISTA NOTIFICHE -->
<div v-if="notifications.length === 0" class="text-center py-5">
<BIIcon name="it-bell" size="xl" color="info" class="mb-3 d-block" />
<h6 class="text-muted">Nessuna notifica</h6>
<p class="small text-muted">Quando riceverai nuovi aggiornamenti sulle tue segnalazioni, appariranno qui.</p>
</div>
<div v-else class="list-group">
<button
v-for="notification in notifications"
:key="notification.id"
type="button"
class="list-group-item list-group-item-action border-0 p-3 mb-3 rounded-2"
:class="{
'bg-light': !notification.read_at,
'bg-white': notification.read_at,
}"
@click="handleNotificationClick(notification)"
>
<div class="d-flex align-items-start gap-3">
<!-- ICONA -->
<div class="flex-shrink-0 mt-1">
<div class="rounded-circle p-2" :class="`bg-${getNotificationColor(notification.type)}`" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;">
<BIIcon :name="getNotificationIcon(notification.type)" size="sm" color="white" />
</div>
</div>
<!-- CONTENUTO -->
<div class="flex-grow-1">
<div class="d-flex align-items-start justify-content-between">
<div>
<h6 class="mb-1 font-weight-bold" :class="{ 'text-dark': !notification.read_at }">
{{ notification.title }}
</h6>
<p class="mb-1 small" :class="{ 'text-muted': notification.read_at }">
{{ notification.body }}
</p>
<small class="text-muted">
{{ formatDate(notification.created_at) }}
</small>
</div>
<!-- INDICATORE NON LETTO -->
<div v-if="!notification.read_at" class="flex-shrink-0">
<span class="badge bg-primary rounded-pill">Nuovo</span>
</div>
</div>
<!-- AZIONI RAPIDE -->
<div v-if="notification.report_id" class="mt-2 d-flex gap-2">
<NuxtLink :to="`/reports/${notification.report_id}`" class="btn btn-sm btn-outline-primary">
<BIIcon name="it-arrow-right" size="xs" class="me-1" />
Vedi segnalazione
</NuxtLink>
<button
v-if="!notification.read_at"
type="button"
class="btn btn-sm btn-outline-secondary"
@click.stop="handleMarkAsRead(notification.id)"
>
Segna come letta
</button>
</div>
</div>
</div>
</button>
</div>
<!-- PAGINATION -->
<Pagination
v-if="notifications.length > 0"
:current-page="page"
:per-page="perPage"
:total-items="totalNotifications"
@page-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.list-group-item {
border: 1px solid #e9ecef;
transition: all 0.2s ease;
cursor: pointer;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
&.bg-light {
border-left: 4px solid #006f9d;
}
}
.rounded-2 {
border-radius: 0.5rem;
}
</style>
-1
View File
@@ -173,7 +173,6 @@ const getStatusBadge = (status: string) => {
<!-- MAP VIEW (Placeholder for Leaflet/Mapbox) --> <!-- MAP VIEW (Placeholder for Leaflet/Mapbox) -->
<div v-else-if="currentView === 'map'" class="map-view-wrapper"> <div v-else-if="currentView === 'map'" class="map-view-wrapper">
<div class="alert alert-info d-flex align-items-center" role="alert"> <div class="alert alert-info d-flex align-items-center" role="alert">
<BIIcon name="it-info-circle" size="sm" class="me-3" />
<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> <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>
<div class="bg-light border rounded d-flex align-items-center justify-content-center" style="height: 500px;"> <div class="bg-light border rounded d-flex align-items-center justify-content-center" style="height: 500px;">
-1
View File
@@ -28,7 +28,6 @@ useHead({
<div class="col-12 col-lg-8 offset-lg-2"> <div class="col-12 col-lg-8 offset-lg-2">
<div class="alert alert-info mb-5" role="alert"> <div class="alert alert-info mb-5" role="alert">
<BIIcon name="it-info-circle" class="me-2" size="sm" />
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. 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> </div>
+400
View File
@@ -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>
+149
View File
@@ -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>
+16
View File
@@ -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
View File
@@ -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')
}) })
+8
View File
@@ -0,0 +1,8 @@
declare module '#app' {
interface PageMeta {
auth?: {
role?: 'CITIZEN' | 'OPERATOR' | 'ADMIN'
}
}
}
export {}
+23
View File
@@ -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'