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
This commit is contained in:
2026-06-12 19:04:19 +02:00 Unverified
parent 794f6e835a
commit 4c6367b328
4 changed files with 451 additions and 0 deletions
@@ -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>
+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>