Compare commits
51
Commits
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
# Public URL of the website
|
NUXT_PUBLIC_URL=http://localhost:3000
|
||||||
NUXT_SITE_URL=http://localhost:3000
|
NUXT_PUBLIC_API=http://localhost:5555
|
||||||
|
|||||||
@@ -22,3 +22,7 @@ logs
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
pnpm-*.yaml
|
||||||
|
.pnpm-store/
|
||||||
|
AGENTS.md
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# BUILD
|
||||||
|
FROM node:22-bookworm AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG NUXT_PUBLIC_API
|
||||||
|
ENV NUXT_PUBLIC_API $NUXT_PUBLIC_API
|
||||||
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
RUN yarn run build
|
||||||
|
|
||||||
|
# RUN
|
||||||
|
FROM node:22-bookworm AS run
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install --production
|
||||||
|
|
||||||
|
COPY --from=build /app/.output/ ./
|
||||||
|
|
||||||
|
ENV PORT=80
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["node", "/app/server/index.mjs"]
|
||||||
+376
-1
@@ -1,3 +1,378 @@
|
|||||||
|
:root {
|
||||||
|
--visione-primary: #06c;
|
||||||
|
--visione-primary-dark: #17324d;
|
||||||
|
--visione-text-muted: #455b71;
|
||||||
|
--visione-text-body: #17324d;
|
||||||
|
--visione-text-soft: #455b71;
|
||||||
|
--visione-success: #008758;
|
||||||
|
--visione-warning: #a66300;
|
||||||
|
--visione-danger: #d9364f;
|
||||||
|
--visione-info: #2f80ed;
|
||||||
|
--visione-light-bg: #f0f6fc;
|
||||||
|
--visione-home-hero-bg: var(--visione-light-bg);
|
||||||
|
--visione-font-sans: "Titillium Web", Arial, sans-serif;
|
||||||
|
--visione-font-mono: "Roboto Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
color: var(--visione-text-body);
|
||||||
|
font-family: var(--visione-font-sans);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: var(--visione-font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
.form-select {
|
||||||
|
background-color: #f7f9fc !important;
|
||||||
|
border-color: #c5d0db !important;
|
||||||
|
color: var(--visione-text-body) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select:focus,
|
||||||
|
select:focus {
|
||||||
|
background-color: #fff !important;
|
||||||
|
border-color: var(--visione-primary) !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
pre,
|
||||||
|
samp {
|
||||||
|
font-family: var(--visione-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
text-decoration: none!important;
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
.h1,
|
||||||
|
.h2,
|
||||||
|
.h3,
|
||||||
|
.h4,
|
||||||
|
.h5,
|
||||||
|
.h6,
|
||||||
|
.card-title,
|
||||||
|
.callout-title {
|
||||||
|
color: var(--visione-primary-dark);
|
||||||
|
font-family: var(--visione-font-sans);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
li,
|
||||||
|
td,
|
||||||
|
th,
|
||||||
|
label,
|
||||||
|
small,
|
||||||
|
.card-text,
|
||||||
|
.card .card-body .card-text,
|
||||||
|
.callout p,
|
||||||
|
.form-text {
|
||||||
|
color: inherit;
|
||||||
|
font-family: var(--visione-font-sans) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--visione-text-soft);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted,
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--visione-text-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-dark {
|
||||||
|
color: var(--visione-text-body) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary,
|
||||||
|
.category.text-primary {
|
||||||
|
color: var(--visione-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success,
|
||||||
|
.category.text-success {
|
||||||
|
color: var(--visione-success) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning {
|
||||||
|
color: var(--visione-warning) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: var(--visione-danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-info {
|
||||||
|
color: var(--visione-info) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-1 {
|
||||||
|
font-size: clamp(3rem, 5vw, 4.75rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-3 {
|
||||||
|
font-size: clamp(2.5rem, 4vw, 3.5rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-6 {
|
||||||
|
font-size: clamp(1.75rem, 2.4vw, 2.25rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-light {
|
||||||
|
background-color: var(--visione-home-hero-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html body .navbar .navbar-collapsable .navbar-nav li.nav-item a.nav-link[data-focus-mouse="true"], .navbar .navbar-collapsable .navbar-nav li.nav-item button.nav-link[data-focus-mouse="true"] {
|
||||||
|
border-color: inherit !important
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-hero-wrapper.it-primary,
|
||||||
|
.it-hero-wrapper.it-primary-bg {
|
||||||
|
background: var(--visione-primary) !important;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-hero-wrapper.it-primary h1,
|
||||||
|
.it-hero-wrapper.it-primary p,
|
||||||
|
.it-hero-wrapper.it-primary-bg h1,
|
||||||
|
.it-hero-wrapper.it-primary-bg p {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-hero-wrapper .container {
|
||||||
|
min-height: 240px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-hero-wrapper.hero-compact .container {
|
||||||
|
min-height: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-hero-wrapper .container > .row {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-hero-wrapper .it-hero-text-wrapper {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-hero-wrapper .it-hero-text-wrapper h1,
|
||||||
|
.it-hero-wrapper .it-hero-text-wrapper p {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-hero-wrapper.bg-light .it-hero-text-wrapper p {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-light h1,
|
||||||
|
.bg-light h2,
|
||||||
|
.bg-light h3,
|
||||||
|
.bg-light h4,
|
||||||
|
.bg-light h5,
|
||||||
|
.bg-light h6 {
|
||||||
|
color: var(--visione-primary-dark) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-light p,
|
||||||
|
.bg-light .lead,
|
||||||
|
.bg-light .text-muted,
|
||||||
|
.bg-light .text-secondary {
|
||||||
|
color: var(--visione-text-soft) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: currentColor;
|
||||||
|
fill: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-primary {
|
||||||
|
color: var(--visione-primary) !important;
|
||||||
|
fill: var(--visione-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-success {
|
||||||
|
color: var(--visione-success) !important;
|
||||||
|
fill: var(--visione-success) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-warning {
|
||||||
|
color: var(--visione-warning) !important;
|
||||||
|
fill: var(--visione-warning) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-danger {
|
||||||
|
color: var(--visione-danger) !important;
|
||||||
|
fill: var(--visione-danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-info {
|
||||||
|
color: var(--visione-info) !important;
|
||||||
|
fill: var(--visione-info) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary .icon:not([class*="icon-"]),
|
||||||
|
.badge[class*="bg-"] .icon:not([class*="icon-"]),
|
||||||
|
.rounded-circle[class*="bg-"] .icon:not([class*="icon-"]) {
|
||||||
|
color: #fff;
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-xs {
|
||||||
|
--bs-btn-padding-y: 0.25rem;
|
||||||
|
--bs-btn-padding-x: 0.55rem;
|
||||||
|
--bs-btn-font-size: 0.875rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.btn-xs {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
height: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table-wrapper {
|
||||||
|
border: 1px solid #dfe4ea;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table thead th {
|
||||||
|
background: #f7f9fa;
|
||||||
|
color: var(--visione-text-soft);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table tbody td {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table-id {
|
||||||
|
color: var(--visione-primary-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-address {
|
||||||
|
color: var(--visione-text-soft);
|
||||||
|
display: inline-block;
|
||||||
|
max-width: min(36vw, 26rem);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: bottom;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat {
|
||||||
|
border-left: 4px solid var(--visione-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat .stat-value {
|
||||||
|
color: var(--visione-primary-dark);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
padding-block: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell-title {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell-subtitle {
|
||||||
|
color: var(--visione-text-soft);
|
||||||
|
margin-bottom: 0;
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dfe4ea;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-header-wrapper .header-slim-right-zone .btn .rounded-icon,
|
||||||
|
.it-header-wrapper .header-slim-right-zone .btn .rounded-icon .icon {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-footer,
|
||||||
|
.it-footer a,
|
||||||
|
.it-footer h2,
|
||||||
|
.it-footer h3,
|
||||||
|
.it-footer h4,
|
||||||
|
.it-footer .it-brand-text,
|
||||||
|
.it-footer .it-brand-text h2,
|
||||||
|
.it-footer .it-brand-text h3,
|
||||||
|
.it-footer .member-name,
|
||||||
|
.it-footer .icon {
|
||||||
|
color: #fff !important;
|
||||||
|
fill: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.page-shell {
|
||||||
|
padding-block: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-hero-wrapper .container {
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-hero-wrapper .it-hero-text-wrapper {
|
||||||
|
padding-left: 1rem !important;
|
||||||
|
padding-right: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-address {
|
||||||
|
max-width: 14rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
const iconAliases: Record<string, string> = {
|
||||||
|
'it-bell': 'it-info-circle',
|
||||||
|
'it-grid': 'it-list',
|
||||||
|
'it-history': 'it-clock',
|
||||||
|
'it-home': 'it-pa',
|
||||||
|
'it-image': 'it-file-image',
|
||||||
|
'it-location': 'it-map-marker-circle',
|
||||||
|
'it-map': 'it-map-marker-circle',
|
||||||
|
'it-piu': 'it-plus',
|
||||||
|
'it-star': 'it-star-full',
|
||||||
|
'it-write': 'it-pencil',
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
name: string
|
name: string
|
||||||
size?: 'xs' | 'sm' | 'lg' | 'xl'
|
size?: 'xs' | 'sm' | 'lg' | 'xl'
|
||||||
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'white'
|
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'white'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const resolvedName = computed(() => iconAliases[props.name] || props.name)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -15,6 +30,6 @@ defineProps<{
|
|||||||
]"
|
]"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<use :href="`/sprites.svg#${name}`"></use>
|
<use :href="`/sprites.svg#${resolvedName}`"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
confirmClass?: string
|
||||||
|
pending?: boolean
|
||||||
|
}>(), {
|
||||||
|
confirmLabel: 'Conferma',
|
||||||
|
cancelLabel: 'Annulla',
|
||||||
|
confirmClass: 'btn-danger',
|
||||||
|
pending: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
cancel: []
|
||||||
|
confirm: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleBackdropClick = (event: MouseEvent) => {
|
||||||
|
if (event.target === event.currentTarget && !props.pending) {
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="open">
|
||||||
|
<div class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
|
||||||
|
<div
|
||||||
|
class="modal fade show d-block"
|
||||||
|
tabindex="-1"
|
||||||
|
style="z-index: 1050;"
|
||||||
|
@click="handleBackdropClick"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content shadow">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ title }}</h5>
|
||||||
|
<button type="button" class="btn-close" :disabled="pending" @click="emit('cancel')"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="mb-0">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" :disabled="pending" @click="emit('cancel')">
|
||||||
|
{{ cancelLabel }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn" :class="confirmClass" :disabled="pending" @click="emit('confirm')">
|
||||||
|
<span v-if="pending" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
{{ confirmLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -14,8 +14,9 @@ const { sortedMembers } = useMembers();
|
|||||||
<div class="row clearfix">
|
<div class="row clearfix">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<div class="it-brand-wrapper">
|
<div class="it-brand-wrapper">
|
||||||
<a href="#" data-focus-mouse="false">
|
<a href="#" data-focus-mouse="false" class="d-flex align-items-center">
|
||||||
<BIIcon name="it-code-circle" />
|
<img src="/icon.svg" alt="Logo Visione" style="width: 42px; height: 42px; margin-right: 12px;" />
|
||||||
|
|
||||||
<div class="it-brand-text">
|
<div class="it-brand-text">
|
||||||
<h2>Visione</h2>
|
<h2>Visione</h2>
|
||||||
<h3 class="d-none d-md-block">Segnalazioni anonime e sicure</h3>
|
<h3 class="d-none d-md-block">Segnalazioni anonime e sicure</h3>
|
||||||
@@ -47,10 +48,9 @@ const { sortedMembers } = useMembers();
|
|||||||
<div class="it-footer-small-prints clearfix">
|
<div class="it-footer-small-prints clearfix">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<ul class="it-footer-small-prints-list list-inline mb-0 d-flex flex-column flex-md-row">
|
<ul class="it-footer-small-prints-list list-inline mb-0 d-flex flex-column flex-md-row">
|
||||||
<li class="list-inline-item"><a href="#">Note legali</a></li>
|
<li class="list-inline-item"><a href="/note-legali">Note legali</a></li>
|
||||||
<li class="list-inline-item"><a href="#">Privacy policy</a></li>
|
<li class="list-inline-item"><a href="/privacy">Privacy policy</a></li>
|
||||||
<li class="list-inline-item"><a href="/sitemap.xml">Mappa del sito</a></li>
|
<li class="list-inline-item"><a href="/sitemap.xml">Mappa del sito</a></li>
|
||||||
<li class="list-inline-item"><a href="https://form.agid.gov.it/view/xyz">Dichiarazione di accessibilità <span class="visually-hidden">(link esterno su sito AgID)</span></a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+65
-21
@@ -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, () => {
|
||||||
@@ -13,12 +12,13 @@ watch(() => route.path, () => {
|
|||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ name: 'Home', path: '/' },
|
{ name: 'Home', path: '/' },
|
||||||
{ name: 'Fai una segnalazione', path: '/reports/create' },
|
{ name: 'Fai una segnalazione', path: '/reports/new' },
|
||||||
{ name: 'Esplora i dati', path: '/open-data' },
|
{ name: 'Esplora i dati', path: '/open-data' },
|
||||||
{ 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()
|
||||||
|
const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ? 'Pannello' : 'Dashboard')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -28,33 +28,63 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="it-header-slim-wrapper-content">
|
<div class="it-header-slim-wrapper-content">
|
||||||
<a class="d-none d-lg-block navbar-brand" href="https://www.interno.gov.it/it/ministero/dipartimenti/dipartimento-pubblica-sicurezza">Dipartimento della Pubblica Sicurezza</a>
|
<a class="d-none d-lg-block navbar-brand"
|
||||||
|
href="https://www.cittametropolitana.ba.it/">
|
||||||
|
Città metropolitana di Bari
|
||||||
|
</a>
|
||||||
<div class="nav-mobile">
|
<div class="nav-mobile">
|
||||||
<nav aria-label="Navigazione secondaria">
|
<nav aria-label="Navigazione secondaria">
|
||||||
<a class="it-opener d-lg-none" data-bs-toggle="collapse" href="https://www.interno.gov.it/it/ministero/dipartimenti/dipartimento-pubblica-sicurezza" role="button" aria-expanded="false" >
|
<a class="it-opener d-lg-none" data-bs-toggle="collapse"
|
||||||
<span>Dipartmento della Pubblica Sicureazza</span>
|
href="https://www.cittametropolitana.ba.it/"
|
||||||
<BIIcon name="it-expand" />
|
role="button" aria-expanded="false">
|
||||||
|
<span>Città metropolitana di Bari</span>
|
||||||
|
<BIIcon name="it-expand"/>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="it-header-slim-right-zone">
|
|
||||||
<div class="it-access-top-wrapper">
|
<div class="header-slim-right-zone" role="navigation">
|
||||||
<!-- Dynamic Login / Area Personale Button -->
|
<div v-if="isAuthenticated" class="d-flex align-items-center gap-2">
|
||||||
<NuxtLink v-if="!isAuthenticated" class="btn btn-primary btn-sm" to="/auth/login">
|
<NuxtLink class="btn btn-primary btn-icon btn-full" to="/dashboard"
|
||||||
Accedi
|
data-element="personal-area-dashboard" data-focus-mouse="false">
|
||||||
|
<span class="rounded-icon" aria-hidden="true">
|
||||||
|
<BIIcon name="it-list" color="black" />
|
||||||
|
</span>
|
||||||
|
<span class="d-none d-lg-block">{{ panelLabel }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink v-else class="btn btn-primary btn-sm" to="/dashboard">
|
|
||||||
Area Personale
|
<NuxtLink class="btn btn-primary btn-icon btn-full" to="/profile"
|
||||||
|
data-element="personal-area-dashboard" data-focus-mouse="false">
|
||||||
|
<span class="rounded-icon" aria-hidden="true">
|
||||||
|
<BIIcon name="it-user" color="black" />
|
||||||
|
</span>
|
||||||
|
<span class="d-none d-lg-block">{{ user?.first_name }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<button class="btn btn-primary btn-icon btn-full border-0" @click="logout"
|
||||||
|
data-element="personal-area-logout" data-focus-mouse="false">
|
||||||
|
<span class="rounded-icon" aria-hidden="true">
|
||||||
|
<BIIcon name="it-logout" color="black" />
|
||||||
|
</span>
|
||||||
|
<span class="d-none d-lg-block">Esci</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NuxtLink v-else class="btn btn-primary btn-icon btn-full" to="/auth/login"
|
||||||
|
data-element="personal-area-login" data-focus-mouse="false">
|
||||||
|
<span class="rounded-icon" aria-hidden="true">
|
||||||
|
<BIIcon name="it-user" color="black" />
|
||||||
|
</span>
|
||||||
|
<span class="d-none d-lg-block">Login</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CENTER HEADER (Brand) -->
|
|
||||||
<div class="it-nav-wrapper">
|
<div class="it-nav-wrapper">
|
||||||
<div class="it-header-center-wrapper">
|
<div class="it-header-center-wrapper">
|
||||||
<div class="container-xxl">
|
<div class="container-xxl">
|
||||||
@@ -63,10 +93,10 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
|
|||||||
<div class="it-header-center-content-wrapper">
|
<div class="it-header-center-content-wrapper">
|
||||||
<div class="it-brand-wrapper">
|
<div class="it-brand-wrapper">
|
||||||
<NuxtLink to="/">
|
<NuxtLink to="/">
|
||||||
<BIIcon name="it-pa" />
|
<img src="/icon.svg" alt="Logo Visione" style="width: 48px; height: 48px; margin-right: 16px;" />
|
||||||
<div class="it-brand-text">
|
<div class="it-brand-text">
|
||||||
<div class="it-brand-title">Visione</div>
|
<div class="it-brand-title">Visione</div>
|
||||||
<div class="it-brand-tagline d-none d-md-block">Un'occhio per la sicurezza del cittadino.</div>
|
<div class="it-brand-tagline d-none d-md-block">Un occhio per la sicurezza del cittadino.</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +117,7 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
|
|||||||
aria-label="Mostra la navigazione"
|
aria-label="Mostra la navigazione"
|
||||||
@click="isMobileMenuOpen = true"
|
@click="isMobileMenuOpen = true"
|
||||||
>
|
>
|
||||||
<BIIcon name="it-burger" />
|
<BIIcon name="it-burger"/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -106,7 +136,7 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
|
|||||||
<div class="close-div">
|
<div class="close-div">
|
||||||
<button class="btn close-menu" type="button" @click="isMobileMenuOpen = false">
|
<button class="btn close-menu" type="button" @click="isMobileMenuOpen = false">
|
||||||
<span class="visually-hidden">Nascondi la navigazione</span>
|
<span class="visually-hidden">Nascondi la navigazione</span>
|
||||||
<BIIcon name="it-close-big" />
|
<BIIcon name="it-close-big"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,11 +171,25 @@ const isAuthenticated = ref(false) // Replace with: const token = useCookie('aut
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
:deep(.it-header-slim-wrapper) {
|
||||||
|
background-color: #036;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.it-nav-wrapper > div) {
|
||||||
|
background-color: rgb(0, 76.5, 153);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.it-header-slim-wrapper .it-header-slim-wrapper-content .header-slim-right-zone a),
|
||||||
|
:deep(.it-header-slim-wrapper .it-header-slim-wrapper-content .header-slim-right-zone button) {
|
||||||
|
background-color: rgb(0, 76.5, 153);
|
||||||
|
}
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay.show {
|
.overlay.show {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
modelValue?: File[]
|
||||||
|
maxFiles?: number
|
||||||
|
acceptedTypes?: string
|
||||||
|
maxFileSize?: number // in MB
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:modelValue', files: File[]): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: () => [],
|
||||||
|
maxFiles: 5,
|
||||||
|
acceptedTypes: 'image/*,video/*',
|
||||||
|
maxFileSize: 50,
|
||||||
|
disabled: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const fileInputRef = ref<HTMLInputElement>()
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const files = computed({
|
||||||
|
get: () => props.modelValue || [],
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFilesSelected = (selectedFiles: FileList | null) => {
|
||||||
|
if (!selectedFiles) return
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
const newFiles = Array.from(selectedFiles)
|
||||||
|
|
||||||
|
if (files.value.length + newFiles.length > props.maxFiles) {
|
||||||
|
error.value = `Massimo ${props.maxFiles} file consentiti`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of newFiles) {
|
||||||
|
if (file.size > props.maxFileSize! * 1024 * 1024) {
|
||||||
|
error.value = `Il file ${file.name} supera il limite di ${props.maxFileSize}MB`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.value = [...files.value, ...newFiles]
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
files.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFileDialog = () => {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
const mb = Math.round(bytes / (1024 * 1024) * 10) / 10
|
||||||
|
return mb > 0 ? `${mb}MB` : 'Piccolo'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileUrl = (file: File) => {
|
||||||
|
return URL.createObjectURL(file)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="media-uploader">
|
||||||
|
<div
|
||||||
|
class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer position-relative"
|
||||||
|
:class="{ 'border-primary bg-primary bg-opacity-10': isDragging, 'bg-light': !isDragging }"
|
||||||
|
@dragover.prevent="isDragging = true"
|
||||||
|
@dragleave.prevent="isDragging = false"
|
||||||
|
@drop.prevent="isDragging = false; handleFilesSelected(($event as DragEvent).dataTransfer?.files || null)"
|
||||||
|
@click="!disabled && openFileDialog()"
|
||||||
|
>
|
||||||
|
<BIIcon name="it-upload" size="xl" color="primary" class="mb-3" />
|
||||||
|
<div class="h6 font-weight-bold">
|
||||||
|
Trascina i file qui o <span class="text-primary text-decoration-underline">clicca per selezionare</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block mt-2">
|
||||||
|
Massimo {{ maxFiles }} file ({{ maxFileSize }}MB max per file). Tipi: {{ acceptedTypes }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
:multiple="maxFiles > 1"
|
||||||
|
:accept="acceptedTypes"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="d-none"
|
||||||
|
@change="handleFilesSelected(($event.target as HTMLInputElement).files)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger mt-3 py-2 small" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="files.length > 0" class="mt-4 row g-3">
|
||||||
|
<div v-for="(file, idx) in files" :key="`file-${idx}`" class="col-6 col-md-4 col-lg-3">
|
||||||
|
<div class="card h-100 position-relative shadow-sm border-0">
|
||||||
|
<div class="ratio ratio-1x1 bg-light rounded-top overflow-hidden">
|
||||||
|
<img v-if="file.type.startsWith('image/')" :src="getFileUrl(file)" class="object-fit-cover" :alt="file.name" />
|
||||||
|
<video v-else-if="file.type.startsWith('video/')" :src="getFileUrl(file)" class="object-fit-cover"></video>
|
||||||
|
<div v-else class="d-flex align-items-center justify-content-center bg-secondary bg-opacity-25">
|
||||||
|
<BIIcon name="it-file" size="xl" color="secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger btn-sm position-absolute top-0 end-0 m-1 rounded-circle p-1 d-flex align-items-center justify-content-center shadow"
|
||||||
|
style="width: 28px; height: 28px; z-index: 10;"
|
||||||
|
@click.stop="removeFile(idx)"
|
||||||
|
:disabled="disabled"
|
||||||
|
title="Rimuovi file"
|
||||||
|
>
|
||||||
|
<BIIcon name="it-close" size="sm" color="white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="card-body p-2 d-flex flex-column justify-content-between">
|
||||||
|
<div class="small text-truncate fw-semibold" :title="file.name">{{ file.name }}</div>
|
||||||
|
<div class="small text-muted d-flex justify-content-between align-items-center mt-1">
|
||||||
|
<span>{{ formatFileSize(file.size) }}</span>
|
||||||
|
<div class="progress flex-grow-1 ms-2" style="height: 4px;" title="Pronto per l'upload">
|
||||||
|
<div class="progress-bar bg-success w-100" role="progressbar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-area {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.upload-area:hover:not(.border-primary) {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
border-color: #0066cc !important;
|
||||||
|
}
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.border-dashed {
|
||||||
|
border-style: dashed !important;
|
||||||
|
}
|
||||||
|
.border-2 {
|
||||||
|
border-width: 2px !important;
|
||||||
|
}
|
||||||
|
.object-fit-cover {
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Emits {
|
||||||
|
(e: 'send', message: string, files: File[]): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const message = ref('')
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const files = ref<File[]>([])
|
||||||
|
const fileInputRef = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (message.value.trim() === '' && files.value.length === 0) return
|
||||||
|
|
||||||
|
emit('send', message.value, [...files.value])
|
||||||
|
message.value = ''
|
||||||
|
files.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target.files) {
|
||||||
|
const newFiles = Array.from(target.files)
|
||||||
|
files.value = [...files.value, ...newFiles].slice(0, 5) // max 5
|
||||||
|
}
|
||||||
|
if (fileInputRef.value) {
|
||||||
|
fileInputRef.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (idx: number) => {
|
||||||
|
files.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="message-composer mt-3">
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div v-if="files.length > 0" class="message-files mb-3">
|
||||||
|
<div v-for="(file, idx) in files" :key="idx" class="message-file-chip">
|
||||||
|
<BIIcon name="it-clip" size="xs" />
|
||||||
|
<span class="text-truncate file-name">{{ file.name }}</span>
|
||||||
|
<button type="button" class="btn-close btn-close-sm" @click="removeFile(idx)" aria-label="Rimuovi allegato"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer-shell">
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="d-none"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
accept="image/*,video/*,.pdf,.doc,.docx"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-primary btn-icon composer-attach"
|
||||||
|
title="Allega file"
|
||||||
|
@click="fileInputRef?.click()"
|
||||||
|
:disabled="isSubmitting || files.length >= 5"
|
||||||
|
>
|
||||||
|
<BIIcon name="it-clip" size="sm" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
v-model="message"
|
||||||
|
class="form-control composer-textarea"
|
||||||
|
placeholder="Scrivi aggiornamento o domanda"
|
||||||
|
rows="2"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary composer-submit"
|
||||||
|
:disabled="isSubmitting || (message.trim() === '' && files.length === 0)"
|
||||||
|
>
|
||||||
|
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
<BIIcon v-else name="it-arrow-right" size="sm" color="white" class="me-1" />
|
||||||
|
Invia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small class="text-muted d-block mt-2">
|
||||||
|
`Ctrl` + `Invio` per inviare. Fino a 5 allegati.
|
||||||
|
</small>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-composer {
|
||||||
|
border-top: 1px solid #d9e3ee;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-file-chip {
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9e3ee;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
max-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-shell {
|
||||||
|
align-items: flex-end;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #c5d0db;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-shell:focus-within {
|
||||||
|
border-color: #06c;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-attach {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-textarea {
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
min-height: 4rem;
|
||||||
|
padding: 0;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-submit {
|
||||||
|
min-width: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close-sm {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.composer-shell {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-textarea {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-submit {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Message } from '~/types/api'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
messages: Message[]
|
||||||
|
isLoading?: boolean
|
||||||
|
canFlagMessage?: (message: Message) => boolean
|
||||||
|
isFlaggingMessageId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
isLoading: false,
|
||||||
|
canFlagMessage: () => false,
|
||||||
|
isFlaggingMessageId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'flag', message: Message): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const today = new Date()
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return 'Ieri'
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit', year: '2-digit' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userId
|
||||||
|
|
||||||
|
const senderInitials = (msg: Message): string =>
|
||||||
|
`${msg.first_name?.[0] || ''}${msg.last_name?.[0] || ''}`.toUpperCase() || '?'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="message-list">
|
||||||
|
<div v-if="isLoading" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Caricamento...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="messages.length === 0" class="message-empty-state" role="alert">
|
||||||
|
<BIIcon name="it-comment" size="lg" color="primary" class="mb-2" />
|
||||||
|
<strong class="d-block mb-1">Nessun messaggio</strong>
|
||||||
|
<span>Usa chat per aggiornamenti rapidi tra cittadino e operatori.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="messages-container">
|
||||||
|
<div
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
class="message-row"
|
||||||
|
:class="{ 'is-current-user': isCurrentUser(msg.user_id) }"
|
||||||
|
>
|
||||||
|
<div v-if="!isCurrentUser(msg.user_id)" class="message-avatar" aria-hidden="true">
|
||||||
|
{{ senderInitials(msg) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="message-bubble-content"
|
||||||
|
:class="{
|
||||||
|
'message-bubble-outgoing': isCurrentUser(msg.user_id),
|
||||||
|
'message-bubble-incoming': !isCurrentUser(msg.user_id)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="message-meta">
|
||||||
|
<span v-if="!isCurrentUser(msg.user_id)" class="message-author">
|
||||||
|
{{ msg.first_name }} {{ msg.last_name }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="message-author">Tu</span>
|
||||||
|
<span class="message-time">
|
||||||
|
{{ formatDate(msg.created_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isCurrentUser(msg.user_id)" class="small font-weight-bold mb-2 text-primary d-none">
|
||||||
|
{{ msg.first_name }} {{ msg.last_name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-body">
|
||||||
|
{{ msg.body }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canFlagMessage(msg)" class="message-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-link btn-xs px-0"
|
||||||
|
:disabled="isFlaggingMessageId === msg.id"
|
||||||
|
@click="emit('flag', msg)"
|
||||||
|
>
|
||||||
|
<span v-if="isFlaggingMessageId === msg.id" class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||||
|
<BIIcon v-else name="it-flag" size="xs" class="me-1" />
|
||||||
|
Segnala messaggio
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-list {
|
||||||
|
border: 1px solid #d9e3ee;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 34rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-empty-state {
|
||||||
|
align-items: center;
|
||||||
|
color: #5c6f82;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 14rem;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
align-items: flex-end;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row.is-current-user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
align-items: center;
|
||||||
|
background: #e6f0fa;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #17324d;
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 2.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
height: 2.25rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble-content {
|
||||||
|
border-radius: 4px;
|
||||||
|
max-width: min(100%, 40rem);
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble-incoming {
|
||||||
|
background: #f5f7f9;
|
||||||
|
border: 1px solid #d9e3ee;
|
||||||
|
color: #17324d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble-outgoing {
|
||||||
|
background: #06c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-meta {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-author {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
opacity: 0.78;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-actions .btn {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.message-bubble-content {
|
||||||
|
max-width: calc(100% - 3rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
currentPage: number
|
||||||
|
perPage: number
|
||||||
|
totalItems: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'page-change', page: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
currentPage: 1,
|
||||||
|
perPage: 10,
|
||||||
|
totalItems: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(props.totalItems / props.perPage))
|
||||||
|
|
||||||
|
const goToPage = (page: number) => {
|
||||||
|
if (page >= 1 && page <= totalPages.value) {
|
||||||
|
emit('page-change', page)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNumbers = computed(() => {
|
||||||
|
const pages: (number | string)[] = []
|
||||||
|
const maxVisible = 5
|
||||||
|
|
||||||
|
if (totalPages.value <= maxVisible) {
|
||||||
|
for (let i = 1; i <= totalPages.value; i++) pages.push(i)
|
||||||
|
} else {
|
||||||
|
if (props.currentPage <= 3) {
|
||||||
|
for (let i = 1; i <= maxVisible - 1; i++) pages.push(i)
|
||||||
|
pages.push('...')
|
||||||
|
pages.push(totalPages.value)
|
||||||
|
} else if (props.currentPage >= totalPages.value - 2) {
|
||||||
|
pages.push(1)
|
||||||
|
pages.push('...')
|
||||||
|
for (let i = totalPages.value - (maxVisible - 2); i <= totalPages.value; i++) pages.push(i)
|
||||||
|
} else {
|
||||||
|
pages.push(1)
|
||||||
|
pages.push('...')
|
||||||
|
for (let i = props.currentPage - 1; i <= props.currentPage + 1; i++) pages.push(i)
|
||||||
|
pages.push('...')
|
||||||
|
pages.push(totalPages.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav v-if="totalPages > 1" class="d-flex justify-content-center mt-5 mb-5">
|
||||||
|
<ul class="pagination mb-0">
|
||||||
|
<!-- Pagina Precedente -->
|
||||||
|
<li class="page-item" :class="{ disabled: currentPage === 1 }">
|
||||||
|
<button
|
||||||
|
class="page-link"
|
||||||
|
@click="goToPage(currentPage - 1)"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
aria-label="Pagina precedente"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">«</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Numeri Pagine -->
|
||||||
|
<li v-for="page in pageNumbers" :key="`page-${page}`" class="page-item" :class="{ active: page === currentPage }">
|
||||||
|
<template v-if="page === '...'">
|
||||||
|
<span class="page-link">...</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
class="page-link"
|
||||||
|
@click="goToPage(page as number)"
|
||||||
|
:aria-label="`Vai a pagina ${page}`"
|
||||||
|
:aria-current="page === currentPage ? 'page' : undefined"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Pagina Successiva -->
|
||||||
|
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
|
||||||
|
<button
|
||||||
|
class="page-link"
|
||||||
|
@click="goToPage(currentPage + 1)"
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
aria-label="Pagina successiva"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">»</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Info Pagina -->
|
||||||
|
<div v-if="totalPages > 1" class="text-center text-muted small mt-3">
|
||||||
|
Pagina {{ currentPage }} di {{ totalPages }} ({{ totalItems }} elementi totali)
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pagination {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Category, CreateReportRequest } from '~/types/api'
|
||||||
|
import type { LeafletMouseEvent, Map as LeafletMap, Marker as LeafletMarker } from 'leaflet'
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isLoading?: boolean
|
||||||
|
categories?: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', report: CreateReportRequest, media: File[]): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeocodingSuggestion {
|
||||||
|
label: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchAddressesResponse {
|
||||||
|
suggestions: GeocodingSuggestion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReverseGeocodeResponse {
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isLoading: false,
|
||||||
|
categories: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
category_id: null as number | null,
|
||||||
|
latitude: null as number | null,
|
||||||
|
longitude: null as number | null,
|
||||||
|
address: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const media = ref<File[]>([])
|
||||||
|
const errors = ref<Record<string, string>>({})
|
||||||
|
const successMessage = ref(false)
|
||||||
|
const isGettingLocation = ref(false)
|
||||||
|
const addressSuggestions = ref<GeocodingSuggestion[]>([])
|
||||||
|
const isSearchingAddress = ref(false)
|
||||||
|
const isResolvingAddress = ref(false)
|
||||||
|
const addressSearchError = ref('')
|
||||||
|
const showAddressSuggestions = ref(false)
|
||||||
|
|
||||||
|
// Map variables
|
||||||
|
const mapContainer = ref<HTMLElement | null>(null)
|
||||||
|
let leaflet: typeof import('leaflet') | null = null
|
||||||
|
let map: LeafletMap | null = null
|
||||||
|
let marker: LeafletMarker | null = null
|
||||||
|
let addressSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let manualLocationSyncTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let addressSearchRequestId = 0
|
||||||
|
let reverseGeocodeRequestId = 0
|
||||||
|
let lastSyncedLocationKey = ''
|
||||||
|
let skipNextAddressSearch = false
|
||||||
|
|
||||||
|
const loadLeaflet = async () => {
|
||||||
|
if (leaflet) return leaflet
|
||||||
|
|
||||||
|
const leafletModule = await import('leaflet')
|
||||||
|
|
||||||
|
// Fix per le icone di Leaflet in Nuxt/Vite
|
||||||
|
const defaultIcon = leafletModule.icon({
|
||||||
|
iconUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon.png',
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon-2x.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/[email protected]/dist/images/marker-shadow.png',
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
popupAnchor: [1, -34],
|
||||||
|
shadowSize: [41, 41]
|
||||||
|
})
|
||||||
|
leafletModule.Marker.prototype.options.icon = defaultIcon
|
||||||
|
|
||||||
|
leaflet = leafletModule
|
||||||
|
return leaflet
|
||||||
|
}
|
||||||
|
|
||||||
|
const toLocationKey = (lat: number, lng: number) => `${lat.toFixed(6)}:${lng.toFixed(6)}`
|
||||||
|
|
||||||
|
const clearAddressSuggestions = () => {
|
||||||
|
addressSuggestions.value = []
|
||||||
|
showAddressSuggestions.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelPendingAddressSearch = () => {
|
||||||
|
addressSearchRequestId += 1
|
||||||
|
isSearchingAddress.value = false
|
||||||
|
|
||||||
|
if (addressSearchTimeout) {
|
||||||
|
clearTimeout(addressSearchTimeout)
|
||||||
|
addressSearchTimeout = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelPendingReverseGeocode = () => {
|
||||||
|
reverseGeocodeRequestId += 1
|
||||||
|
isResolvingAddress.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAddressValue = (address: string) => {
|
||||||
|
cancelPendingAddressSearch()
|
||||||
|
skipNextAddressSearch = true
|
||||||
|
form.value.address = address
|
||||||
|
|
||||||
|
if (errors.value.address && address.trim()) {
|
||||||
|
errors.value.address = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAddressSuggestions = async (query: string) => {
|
||||||
|
const requestId = ++addressSearchRequestId
|
||||||
|
isSearchingAddress.value = true
|
||||||
|
addressSearchError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch<SearchAddressesResponse>('/api/geocoding/search', {
|
||||||
|
query: { q: query }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (requestId !== addressSearchRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addressSuggestions.value = response.suggestions
|
||||||
|
showAddressSuggestions.value = response.suggestions.length > 0
|
||||||
|
|
||||||
|
if (!response.suggestions.length) {
|
||||||
|
addressSearchError.value = 'Nessun indirizzo trovato nel Comune di Bari.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (requestId !== addressSearchRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAddressSuggestions()
|
||||||
|
addressSearchError.value = 'Ricerca indirizzi non disponibile al momento.'
|
||||||
|
} finally {
|
||||||
|
if (requestId === addressSearchRequestId) {
|
||||||
|
isSearchingAddress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAddressFromCoordinates = async (lat: number, lng: number, fallbackAddress = '') => {
|
||||||
|
const requestId = ++reverseGeocodeRequestId
|
||||||
|
isResolvingAddress.value = true
|
||||||
|
addressSearchError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch<ReverseGeocodeResponse>('/api/geocoding/reverse', {
|
||||||
|
query: { lat, lng }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (requestId !== reverseGeocodeRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedAddress = response.address?.trim() || fallbackAddress.trim()
|
||||||
|
|
||||||
|
if (resolvedAddress) {
|
||||||
|
setAddressValue(resolvedAddress)
|
||||||
|
clearAddressSuggestions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.value.address.trim()) {
|
||||||
|
addressSearchError.value = 'Indirizzo non trovato per coordinate selezionate.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (requestId !== reverseGeocodeRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackAddress.trim()) {
|
||||||
|
setAddressValue(fallbackAddress.trim())
|
||||||
|
clearAddressSuggestions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.value.address.trim()) {
|
||||||
|
addressSearchError.value = 'Impossibile recuperare indirizzo da coordinate.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === reverseGeocodeRequestId) {
|
||||||
|
isResolvingAddress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddressFocus = () => {
|
||||||
|
if (addressSuggestions.value.length > 0) {
|
||||||
|
showAddressSuggestions.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddressBlur = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
showAddressSuggestions.value = false
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateLocation = async (
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
options: {
|
||||||
|
zoomTo?: boolean
|
||||||
|
syncAddress?: boolean
|
||||||
|
fallbackAddress?: string
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
const { zoomTo = false, syncAddress = true, fallbackAddress = '' } = options
|
||||||
|
|
||||||
|
if (!isWithinBariBounds(lat, lng)) {
|
||||||
|
errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundedLat = Number(lat.toFixed(6))
|
||||||
|
const roundedLng = Number(lng.toFixed(6))
|
||||||
|
|
||||||
|
lastSyncedLocationKey = toLocationKey(roundedLat, roundedLng)
|
||||||
|
form.value.latitude = roundedLat
|
||||||
|
form.value.longitude = roundedLng
|
||||||
|
|
||||||
|
if (map && leaflet) {
|
||||||
|
if (!marker) {
|
||||||
|
marker = leaflet.marker([roundedLat, roundedLng]).addTo(map)
|
||||||
|
} else {
|
||||||
|
marker.setLatLng([roundedLat, roundedLng])
|
||||||
|
}
|
||||||
|
if (zoomTo) {
|
||||||
|
map.setView([roundedLat, roundedLng], 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.value.location) {
|
||||||
|
errors.value.location = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncAddress) {
|
||||||
|
await resolveAddressFromCoordinates(roundedLat, roundedLng, fallbackAddress)
|
||||||
|
} else if (fallbackAddress.trim()) {
|
||||||
|
setAddressValue(fallbackAddress.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initMap = async () => {
|
||||||
|
if (!import.meta.client || !mapContainer.value || map) return
|
||||||
|
|
||||||
|
const L = await loadLeaflet()
|
||||||
|
if (!mapContainer.value || map) return
|
||||||
|
|
||||||
|
const bariBounds = L.latLngBounds(
|
||||||
|
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||||
|
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||||
|
)
|
||||||
|
|
||||||
|
map = L.map(mapContainer.value, {
|
||||||
|
maxBounds: bariBounds,
|
||||||
|
maxBoundsViscosity: 1,
|
||||||
|
minZoom: 12
|
||||||
|
}).setView(BARI_CENTER, 12)
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map)
|
||||||
|
map.fitBounds(bariBounds)
|
||||||
|
|
||||||
|
map.on('click', (e: LeafletMouseEvent) => {
|
||||||
|
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
|
||||||
|
errors.value.location = 'Puoi inserire segnalazioni solo nel Comune di Bari.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateLocation(e.latlng.lat, e.latlng.lng)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Se c'è già una posizione, imposta il marker
|
||||||
|
if (form.value.latitude && form.value.longitude) {
|
||||||
|
void updateLocation(form.value.latitude, form.value.longitude, { zoomTo: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAddressSuggestion = async (suggestion: GeocodingSuggestion) => {
|
||||||
|
addressSearchError.value = ''
|
||||||
|
clearAddressSuggestions()
|
||||||
|
setAddressValue(suggestion.label)
|
||||||
|
await updateLocation(suggestion.latitude, suggestion.longitude, {
|
||||||
|
zoomTo: true,
|
||||||
|
syncAddress: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddressEnter = async () => {
|
||||||
|
if (addressSuggestions.value.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await selectAddressSuggestion(addressSuggestions.value[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void initMap()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancelPendingAddressSearch()
|
||||||
|
cancelPendingReverseGeocode()
|
||||||
|
|
||||||
|
if (manualLocationSyncTimeout) {
|
||||||
|
clearTimeout(manualLocationSyncTimeout)
|
||||||
|
manualLocationSyncTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
map.remove()
|
||||||
|
map = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
errors.value = {}
|
||||||
|
|
||||||
|
if (!form.value.category_id) {
|
||||||
|
errors.value.category_id = 'Seleziona una categoria'
|
||||||
|
}
|
||||||
|
if (!form.value.latitude || !form.value.longitude) {
|
||||||
|
errors.value.location = 'Imposta la posizione sulla mappa'
|
||||||
|
} else if (!isWithinBariBounds(form.value.latitude, form.value.longitude)) {
|
||||||
|
errors.value.location = 'La posizione deve essere nel Comune di Bari'
|
||||||
|
}
|
||||||
|
if (!form.value.address?.trim()) {
|
||||||
|
errors.value.address = 'Inserisci l\'indirizzo'
|
||||||
|
}
|
||||||
|
if (!form.value.description?.trim() || form.value.description.length < 10) {
|
||||||
|
errors.value.description = 'Descrivi il problema (minimo 10 caratteri)'
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(errors.value).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGeolocation = async () => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
errors.value.location = 'Geolocalizzazione non disponibile nel tuo browser'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isGettingLocation.value = true
|
||||||
|
errors.value.location = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||||
|
navigator.geolocation.getCurrentPosition(resolve, reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
await updateLocation(position.coords.latitude, position.coords.longitude, { zoomTo: true })
|
||||||
|
} catch (error: any) {
|
||||||
|
errors.value.location = 'Impossibile ottenere la posizione. Clicca sulla mappa per inserirla manualmente.'
|
||||||
|
} finally {
|
||||||
|
isGettingLocation.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateForm()) return
|
||||||
|
|
||||||
|
const reportData: CreateReportRequest = {
|
||||||
|
category_id: form.value.category_id!,
|
||||||
|
latitude: form.value.latitude!,
|
||||||
|
longitude: form.value.longitude!,
|
||||||
|
address: form.value.address,
|
||||||
|
description: form.value.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', reportData, media.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.value = {
|
||||||
|
category_id: null,
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
address: '',
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
media.value = []
|
||||||
|
errors.value = {}
|
||||||
|
addressSearchError.value = ''
|
||||||
|
clearAddressSuggestions()
|
||||||
|
cancelPendingAddressSearch()
|
||||||
|
cancelPendingReverseGeocode()
|
||||||
|
lastSyncedLocationKey = ''
|
||||||
|
skipNextAddressSearch = false
|
||||||
|
|
||||||
|
if (marker && map) {
|
||||||
|
map.removeLayer(marker)
|
||||||
|
marker = null
|
||||||
|
map.setView(BARI_CENTER, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => form.value.category_id, () => {
|
||||||
|
if (errors.value.category_id) {
|
||||||
|
errors.value.category_id = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => form.value.address, (newAddress) => {
|
||||||
|
if (skipNextAddressSearch) {
|
||||||
|
skipNextAddressSearch = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelPendingReverseGeocode()
|
||||||
|
|
||||||
|
if (errors.value.address && newAddress.trim()) {
|
||||||
|
errors.value.address = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addressSearchTimeout) {
|
||||||
|
clearTimeout(addressSearchTimeout)
|
||||||
|
addressSearchTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAddress.trim().length < 3) {
|
||||||
|
isSearchingAddress.value = false
|
||||||
|
addressSearchError.value = ''
|
||||||
|
clearAddressSuggestions()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addressSearchTimeout = setTimeout(() => {
|
||||||
|
void fetchAddressSuggestions(newAddress.trim())
|
||||||
|
}, 350)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sincronizza input manuali con la mappa
|
||||||
|
watch([() => form.value.latitude, () => form.value.longitude], ([newLat, newLng]) => {
|
||||||
|
if (newLat && newLng && map && leaflet) {
|
||||||
|
if (!marker || marker.getLatLng().lat !== newLat || marker.getLatLng().lng !== newLng) {
|
||||||
|
if (!marker) {
|
||||||
|
marker = leaflet.marker([newLat, newLng]).addTo(map)
|
||||||
|
} else {
|
||||||
|
marker.setLatLng([newLat, newLng])
|
||||||
|
}
|
||||||
|
map.setView([newLat, newLng], 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newLat || !newLng) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationKey = toLocationKey(newLat, newLng)
|
||||||
|
if (locationKey === lastSyncedLocationKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manualLocationSyncTimeout) {
|
||||||
|
clearTimeout(manualLocationSyncTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
manualLocationSyncTimeout = setTimeout(() => {
|
||||||
|
void updateLocation(newLat, newLng, { zoomTo: true })
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="report-form">
|
||||||
|
<div v-if="successMessage" class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
Segnalazione creata con successo! Reindirizzamento...
|
||||||
|
<button type="button" class="btn-close" @click="successMessage = false"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="category" class="form-label fw-bold">
|
||||||
|
<BIIcon name="it-list" size="sm" color="primary" class="me-2" />
|
||||||
|
Categoria del problema *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="category"
|
||||||
|
v-model.number="form.category_id"
|
||||||
|
class="form-select"
|
||||||
|
:class="{ 'is-invalid': errors.category_id }"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option :value="null">-- Seleziona una categoria --</option>
|
||||||
|
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||||
|
{{ cat.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div v-if="errors.category_id" class="invalid-feedback">
|
||||||
|
{{ errors.category_id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold">
|
||||||
|
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
|
||||||
|
Posizione *
|
||||||
|
</label>
|
||||||
|
<p class="small text-muted mb-2">Le segnalazioni sono abilitate solo entro confini del Comune di Bari.</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-primary w-100 mb-3"
|
||||||
|
@click="getGeolocation"
|
||||||
|
:disabled="isGettingLocation"
|
||||||
|
>
|
||||||
|
<span v-if="isGettingLocation" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
<BIIcon v-else name="it-location" size="sm" class="me-2" />
|
||||||
|
{{ isGettingLocation ? 'Localizzazione in corso...' : 'Usa posizione attuale' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<div
|
||||||
|
ref="mapContainer"
|
||||||
|
class="map-container mb-3 border rounded"
|
||||||
|
:class="{ 'border-danger': errors.location }"
|
||||||
|
@vue:mounted="initMap"
|
||||||
|
></div>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<input
|
||||||
|
v-model.number="form.latitude"
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
:class="{ 'is-invalid': errors.location }"
|
||||||
|
placeholder="Latitudine (Bari)"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<input
|
||||||
|
v-model.number="form.longitude"
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
:class="{ 'is-invalid': errors.location }"
|
||||||
|
placeholder="Longitudine (Bari)"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="errors.location" class="invalid-feedback d-block mt-0 mb-3">
|
||||||
|
{{ errors.location }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="address" class="form-label fw-bold">
|
||||||
|
<BIIcon name="it-home" size="sm" color="primary" class="me-2" />
|
||||||
|
Indirizzo *
|
||||||
|
</label>
|
||||||
|
<div class="position-relative">
|
||||||
|
<input
|
||||||
|
id="address"
|
||||||
|
v-model="form.address"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.address }"
|
||||||
|
placeholder="Es. Via Sparano 12, Bari"
|
||||||
|
autocomplete="street-address"
|
||||||
|
required
|
||||||
|
@focus="handleAddressFocus"
|
||||||
|
@blur="handleAddressBlur"
|
||||||
|
@keydown.enter.prevent="handleAddressEnter"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="showAddressSuggestions && addressSuggestions.length > 0"
|
||||||
|
class="list-group position-absolute w-100 shadow-sm rounded-3 overflow-hidden address-suggestions"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="suggestion in addressSuggestions"
|
||||||
|
:key="`${suggestion.latitude}-${suggestion.longitude}-${suggestion.label}`"
|
||||||
|
type="button"
|
||||||
|
class="list-group-item list-group-item-action text-start"
|
||||||
|
@mousedown.prevent="selectAddressSuggestion(suggestion)"
|
||||||
|
>
|
||||||
|
{{ suggestion.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isSearchingAddress" class="form-text">
|
||||||
|
Ricerca indirizzi in corso...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isResolvingAddress" class="form-text">
|
||||||
|
Recupero indirizzo da coordinate...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="addressSearchError" class="form-text text-muted">
|
||||||
|
{{ addressSearchError }}
|
||||||
|
</div>
|
||||||
|
<div v-if="errors.address" class="invalid-feedback">
|
||||||
|
{{ errors.address }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="description" class="form-label fw-bold">
|
||||||
|
<BIIcon name="it-write" size="sm" color="primary" class="me-2" />
|
||||||
|
Descrizione del problema *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
v-model="form.description"
|
||||||
|
class="form-control"
|
||||||
|
:class="{ 'is-invalid': errors.description }"
|
||||||
|
rows="5"
|
||||||
|
placeholder="Descrivi dettagliatamente il problema (tipo buca stradale, pericolo, ecc.)"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small class="form-text text-muted d-block mt-1">
|
||||||
|
{{ form.description.length }} caratteri (minimo 10)
|
||||||
|
</small>
|
||||||
|
<div v-if="errors.description" class="invalid-feedback d-block">
|
||||||
|
{{ errors.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-bold">
|
||||||
|
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
|
||||||
|
Allega foto/video (opzionale)
|
||||||
|
</label>
|
||||||
|
<MediaUploader v-model="media" :max-files="5" :max-file-size="50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
@click="resetForm"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary btn-lg"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
{{ isLoading ? 'Invio segnalazione...' : 'Invia segnalazione' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.report-form {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
height: 300px;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1; /* Fix for Bootstrap elements overlapping map */
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-suggestions {
|
||||||
|
top: calc(100% + 0.25rem);
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Report } from '~/types/api'
|
||||||
|
|
||||||
|
type ActionVariant = 'open' | 'manage'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reports: Report[]
|
||||||
|
detailBasePath?: string
|
||||||
|
actionVariant?: ActionVariant
|
||||||
|
actionLabel?: string
|
||||||
|
showAssignee?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
detailBasePath: '/reports',
|
||||||
|
actionVariant: 'open',
|
||||||
|
actionLabel: 'Open report',
|
||||||
|
showAssignee: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'take', id: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formatDate = (dateString?: string): string => {
|
||||||
|
if (!dateString) return '-'
|
||||||
|
return new Date(dateString).toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportHref = (report: Report) => `${props.detailBasePath}/${report.id}`
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="table-responsive report-table-wrapper">
|
||||||
|
<table class="table table-hover report-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">ID</th>
|
||||||
|
<th scope="col">Categoria</th>
|
||||||
|
<th scope="col">Indirizzo</th>
|
||||||
|
<th scope="col">Stato</th>
|
||||||
|
<th v-if="showAssignee" scope="col">Operatore</th>
|
||||||
|
<th scope="col">Creazione</th>
|
||||||
|
<th scope="col" class="text-end">Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="report in reports" :key="report.id" class="align-middle">
|
||||||
|
<td class="report-table-id">#{{ report.id }}</td>
|
||||||
|
<td class="text-nowrap">{{ report.category_name || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="report-address" :title="report.address">
|
||||||
|
{{ report.address || '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge :status="report.status" />
|
||||||
|
</td>
|
||||||
|
<td v-if="showAssignee" class="text-muted">
|
||||||
|
{{ report.assigned_operator_id ? `#${report.assigned_operator_id}` : '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted text-nowrap">{{ formatDate(report.created_at) }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button
|
||||||
|
v-if="actionVariant === 'manage'"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-xs"
|
||||||
|
@click="emit('take', report.id)"
|
||||||
|
>
|
||||||
|
Prendi in carico
|
||||||
|
</button>
|
||||||
|
<NuxtLink
|
||||||
|
v-else
|
||||||
|
:to="reportHref(report)"
|
||||||
|
class="btn btn-outline-primary btn-xs btn-icon"
|
||||||
|
:aria-label="actionLabel"
|
||||||
|
:title="actionLabel"
|
||||||
|
>
|
||||||
|
<BIIcon name="it-arrow-right" size="xs" />
|
||||||
|
</NuxtLink>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ReportStatus } from '~/types/api'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: ReportStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const badgeConfig = computed(() => {
|
||||||
|
const config: Record<ReportStatus, { color: string, text: string, icon: string }> = {
|
||||||
|
OPEN: { color: 'info', text: 'Aperta', icon: 'it-info-circle' },
|
||||||
|
UNDER_REVIEW: { color: 'warning', text: 'In Revisione', icon: 'it-clock' },
|
||||||
|
ASSIGNED: { color: 'primary', text: 'Assegnata', icon: 'it-user' },
|
||||||
|
IN_PROGRESS: { color: 'primary', text: 'In Lavorazione', icon: 'it-settings' },
|
||||||
|
RESOLVED: { color: 'success', text: 'Risolta', icon: 'it-check-circle' },
|
||||||
|
REJECTED: { color: 'danger', text: 'Rifiutata', icon: 'it-close-circle' },
|
||||||
|
CLOSED: { color: 'secondary', text: 'Chiusa', icon: 'it-locked' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return config[props.status] || { color: 'secondary', text: props.status, icon: 'it-info-circle' }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="badge badge-pill rounded-pill d-inline-flex align-items-center gap-1" :class="`bg-${badgeConfig.color}`">
|
||||||
|
<BIIcon :name="badgeConfig.icon" size="xs" color="white" />
|
||||||
|
{{ badgeConfig.text }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.badge {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.35em 0.65em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { getOverviewStats } = useAdmin()
|
||||||
|
|
||||||
|
const { data: statsRes, pending } = useAsyncData('admin-stats', () => getOverviewStats())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="callout callout-highlight callout-danger mb-4">
|
||||||
|
<div class="callout-title">Pannello Admin</div>
|
||||||
|
<p>Accesso di livello 1. Da qui puoi gestire le configurazioni di sistema, le categorie e moderare le segnalazioni.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistiche Globali (Mock o Reali) -->
|
||||||
|
<div class="row mb-5" v-if="!pending && statsRes?.ok">
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="card-wrapper card-space">
|
||||||
|
<div class="card card-bg text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="text-primary">{{ statsRes.total_users || 0 }}</h2>
|
||||||
|
<p class="card-text">Utenti Registrati</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="card-wrapper card-space">
|
||||||
|
<div class="card card-bg text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="text-warning">
|
||||||
|
{{ statsRes.open_reports ?? statsRes.reports_by_status?.find(s => s.status === 'OPEN')?.total ?? 0 }}
|
||||||
|
</h2>
|
||||||
|
<p class="card-text">Segn. Aperte</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mb-3">Gestione Sistema</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-md-4 mb-3">
|
||||||
|
<NuxtLink to="/admin/catalog" class="btn btn-outline-primary w-100 py-3 d-flex flex-column align-items-center">
|
||||||
|
<BIIcon name="it-list" size="sm" color="primary" class="mb-2" />
|
||||||
|
Categorie e Team
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 mb-3">
|
||||||
|
<NuxtLink to="/admin/areas" class="btn btn-outline-primary w-100 py-3 d-flex flex-column align-items-center">
|
||||||
|
<BIIcon name="it-map-marker" size="sm" color="primary" class="mb-2" />
|
||||||
|
Aree
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 mb-3">
|
||||||
|
<NuxtLink to="/admin/users" class="btn btn-outline-primary w-100 py-3 d-flex flex-column align-items-center">
|
||||||
|
<BIIcon name="it-user" size="sm" color="primary" class="mb-2" />
|
||||||
|
Gestione Utenti
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 mb-3">
|
||||||
|
<NuxtLink to="/admin/moderation" class="btn btn-outline-danger w-100 py-3 d-flex flex-column align-items-center">
|
||||||
|
<BIIcon name="it-warning" size="sm" color="danger" class="mb-2" />
|
||||||
|
Moderazione Abusi
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Report } from '~/types/api'
|
||||||
|
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { getReports } = useReports()
|
||||||
|
|
||||||
|
const { data: reportsRes, pending } = useAsyncData('citizen-dashboard-reports', () => getReports({}))
|
||||||
|
|
||||||
|
const reports = computed<Report[]>(() => reportsRes.value?.reports || [])
|
||||||
|
const recentReports = computed(() => reports.value.slice(0, 4))
|
||||||
|
|
||||||
|
const stats = computed(() => {
|
||||||
|
return reports.value.reduce(
|
||||||
|
(acc, report) => {
|
||||||
|
acc.total++
|
||||||
|
if (report.status === 'OPEN') acc.open++
|
||||||
|
if (report.status === 'IN_PROGRESS' || report.status === 'ASSIGNED') acc.inProgress++
|
||||||
|
if (report.status === 'RESOLVED' || report.status === 'CLOSED') acc.resolved++
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ total: 0, open: 0, inProgress: 0, resolved: 0 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="card card-bg dashboard-stat h-100">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="stat-value">{{ stats.total }}</div>
|
||||||
|
<div class="small text-muted">Segnalazioni</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="card card-bg dashboard-stat h-100">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="stat-value">{{ stats.open }}</div>
|
||||||
|
<div class="small text-muted">Aperte</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="card card-bg dashboard-stat h-100">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="stat-value">{{ stats.inProgress }}</div>
|
||||||
|
<div class="small text-muted">In corso</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="card card-bg dashboard-stat h-100">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="stat-value">{{ user?.points || 0 }}</div>
|
||||||
|
<div class="small text-muted">Punti cittadino</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||||
|
<NuxtLink to="/reports/new" class="btn btn-primary">
|
||||||
|
<BIIcon name="it-plus-circle" size="sm" color="white" class="me-2" />
|
||||||
|
Nuova segnalazione
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/reports" class="btn btn-outline-primary">
|
||||||
|
<BIIcon name="it-list" size="sm" class="me-2" />
|
||||||
|
Le mie segnalazioni
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bg">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="h5 card-title mb-0">Aggiornamenti recenti</h2>
|
||||||
|
<NuxtLink to="/reports" class="btn btn-link btn-sm pe-0">Vedi tutte</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Caricamento...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="recentReports.length === 0" class="alert alert-info mb-0" role="alert">
|
||||||
|
Non hai ancora creato segnalazioni.
|
||||||
|
</div>
|
||||||
|
<ReportTable v-else :reports="recentReports" action-label="Apri segnalazione" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { getOpenReports, getAssignedReports, takeReport } = useOperator()
|
||||||
|
|
||||||
|
const { data: openReportsRes, refresh: refreshOpen } = useAsyncData('open-reports', () => getOpenReports({ per_page: 5 }))
|
||||||
|
const { data: assignedReportsRes, refresh: refreshAssigned } = useAsyncData('assigned-reports', () => getAssignedReports({ per_page: 5 }))
|
||||||
|
|
||||||
|
const handleTakeReport = async (id: number) => {
|
||||||
|
const res = await takeReport(id)
|
||||||
|
if (res.ok) {
|
||||||
|
await Promise.all([refreshOpen(), refreshAssigned()])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-6 mb-4">
|
||||||
|
<div class="card card-bg h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h3 class="h5 mb-0">Da prendere in carico</h3>
|
||||||
|
<NuxtLink to="/operator" class="btn btn-link btn-sm pe-0">Apri coda</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div v-if="openReportsRes?.ok && openReportsRes.reports.length === 0" class="alert alert-info mb-0">
|
||||||
|
Nessuna nuova segnalazione in ingresso.
|
||||||
|
</div>
|
||||||
|
<ReportTable
|
||||||
|
v-else
|
||||||
|
:reports="openReportsRes?.reports || []"
|
||||||
|
action-variant="manage"
|
||||||
|
detail-base-path="/operator/reports"
|
||||||
|
@take="handleTakeReport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6 mb-4">
|
||||||
|
<div class="card card-bg h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h3 class="h5 mb-0">Le mie assegnazioni</h3>
|
||||||
|
<NuxtLink to="/operator/assigned" class="btn btn-link btn-sm pe-0">Vedi tutte</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div v-if="assignedReportsRes?.ok && assignedReportsRes.reports.length === 0" class="alert alert-info mb-0">
|
||||||
|
Non hai segnalazioni in carico.
|
||||||
|
</div>
|
||||||
|
<ReportTable
|
||||||
|
v-else
|
||||||
|
:reports="assignedReportsRes?.reports || []"
|
||||||
|
detail-base-path="/operator/reports"
|
||||||
|
action-label="Apri segnalazione"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ModerationListResponse,
|
ModerationListResponse,
|
||||||
OkResponse,
|
OkResponse,
|
||||||
OverviewStatsResponse, TeamListResponse, UpdateModerationRequest,
|
OverviewStatsResponse, TeamListResponse, UpdateModerationRequest,
|
||||||
|
UpdateAreaRequest, UpdateCategoryRequest, UpdateTeamRequest,
|
||||||
UpdateUserBanRequest, UpdateUserRolesRequest,
|
UpdateUserBanRequest, UpdateUserRolesRequest,
|
||||||
UserListResponse
|
UserListResponse
|
||||||
} from "~/types/api";
|
} from "~/types/api";
|
||||||
@@ -21,23 +22,41 @@ export const useAdmin = () => {
|
|||||||
useApiFetch<OkResponse>(`/api/v1/admin/users/${id}/roles`, { method: 'PATCH', body })
|
useApiFetch<OkResponse>(`/api/v1/admin/users/${id}/roles`, { method: 'PATCH', body })
|
||||||
|
|
||||||
const getAreas = () =>
|
const getAreas = () =>
|
||||||
useApiFetch<AreaListResponse>('/api/v1/admin/areas', { method: 'GET' })
|
useApiFetch<AreaListResponse>('/api/v1/areas', { method: 'GET' })
|
||||||
|
|
||||||
const createArea = (body: CreateAreaRequest) =>
|
const createArea = (body: CreateAreaRequest) =>
|
||||||
useApiFetch<OkResponse>('/api/v1/admin/areas', { method: 'POST', body })
|
useApiFetch<OkResponse>('/api/v1/admin/areas', { method: 'POST', body })
|
||||||
|
|
||||||
|
const updateArea = (id: number, body: UpdateAreaRequest) =>
|
||||||
|
useApiFetch<OkResponse>(`/api/v1/admin/areas/${id}`, { method: 'PATCH', body })
|
||||||
|
|
||||||
|
const deleteArea = (id: number) =>
|
||||||
|
useApiFetch<OkResponse>(`/api/v1/admin/areas/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
const getCategories = () =>
|
const getCategories = () =>
|
||||||
useApiFetch<CategoryListResponse>('/api/v1/admin/categories', { method: 'GET' })
|
useApiFetch<CategoryListResponse>('/api/v1/admin/categories', { method: 'GET' })
|
||||||
|
|
||||||
const createCategory = (body: CreateCategoryRequest) =>
|
const createCategory = (body: CreateCategoryRequest) =>
|
||||||
useApiFetch<OkResponse>('/api/v1/admin/categories', { method: 'POST', body })
|
useApiFetch<OkResponse>('/api/v1/admin/categories', { method: 'POST', body })
|
||||||
|
|
||||||
|
const updateCategory = (id: number, body: UpdateCategoryRequest) =>
|
||||||
|
useApiFetch<OkResponse>(`/api/v1/admin/categories/${id}`, { method: 'PATCH', body })
|
||||||
|
|
||||||
|
const deleteCategory = (id: number) =>
|
||||||
|
useApiFetch<OkResponse>(`/api/v1/admin/categories/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
const getTeams = () =>
|
const getTeams = () =>
|
||||||
useApiFetch<TeamListResponse>('/api/v1/admin/teams', { method: 'GET' })
|
useApiFetch<TeamListResponse>('/api/v1/admin/teams', { method: 'GET' })
|
||||||
|
|
||||||
const createTeam = (body: CreateTeamRequest) =>
|
const createTeam = (body: CreateTeamRequest) =>
|
||||||
useApiFetch<OkResponse>('/api/v1/admin/teams', { method: 'POST', body })
|
useApiFetch<OkResponse>('/api/v1/admin/teams', { method: 'POST', body })
|
||||||
|
|
||||||
|
const updateTeam = (id: number, body: UpdateTeamRequest) =>
|
||||||
|
useApiFetch<OkResponse>(`/api/v1/admin/teams/${id}`, { method: 'PATCH', body })
|
||||||
|
|
||||||
|
const deleteTeam = (id: number) =>
|
||||||
|
useApiFetch<OkResponse>(`/api/v1/admin/teams/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
const getModerationFlags = (query?: { status?: string; page?: number; per_page?: number }) =>
|
const getModerationFlags = (query?: { status?: string; page?: number; per_page?: number }) =>
|
||||||
useApiFetch<ModerationListResponse>('/api/v1/admin/moderation', { method: 'GET', query })
|
useApiFetch<ModerationListResponse>('/api/v1/admin/moderation', { method: 'GET', query })
|
||||||
|
|
||||||
@@ -51,10 +70,16 @@ export const useAdmin = () => {
|
|||||||
updateUserRoles,
|
updateUserRoles,
|
||||||
getAreas,
|
getAreas,
|
||||||
createArea,
|
createArea,
|
||||||
|
updateArea,
|
||||||
|
deleteArea,
|
||||||
getCategories,
|
getCategories,
|
||||||
createCategory,
|
createCategory,
|
||||||
|
updateCategory,
|
||||||
|
deleteCategory,
|
||||||
getTeams,
|
getTeams,
|
||||||
createTeam,
|
createTeam,
|
||||||
|
updateTeam,
|
||||||
|
deleteTeam,
|
||||||
getModerationFlags,
|
getModerationFlags,
|
||||||
updateModerationFlag,
|
updateModerationFlag,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,34 @@ 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 nuxtApp = useNuxtApp()
|
||||||
|
const tokenCookie = useCookie('auth_token', { path: '/' })
|
||||||
|
const tokenState = useState<string | null>('auth_token_state')
|
||||||
|
|
||||||
|
const tokenValue = tokenState.value ?? tokenCookie.value
|
||||||
|
|
||||||
const defaults: Parameters<typeof $fetch>[1] = {
|
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) {
|
||||||
|
console.error(`[API] Authentication error (${status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,78 @@ import type {
|
|||||||
OkResponse,
|
OkResponse,
|
||||||
OtpRequestResponse, PasswordLoginRequest, RegisterCitizenRequest,
|
OtpRequestResponse, PasswordLoginRequest, RegisterCitizenRequest,
|
||||||
RegisterCitizenResponse, RewardsResponse,
|
RegisterCitizenResponse, RewardsResponse,
|
||||||
TokenResponse, VerifyOtpRequest
|
TokenResponse, VerifyOtpRequest, UpdateProfileRequest
|
||||||
} from "~/types/api";
|
} from "~/types/api";
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
|
const nuxtApp = useNuxtApp()
|
||||||
|
|
||||||
|
const token = useCookie<string | null>('auth_token', {
|
||||||
|
maxAge: 60 * 60 * 12,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
watch: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const tokenState = useState<string | null>('auth_token_state', () => token.value ?? null)
|
||||||
|
|
||||||
|
type UserContext = CurrentUserResponse['user']
|
||||||
|
const user = useState<UserContext | null>('auth_user', () => null)
|
||||||
|
const isSyncingUser = useState<boolean>('auth_syncing', () => false)
|
||||||
|
const isAuthenticated = computed(() => !!(tokenState.value || token.value))
|
||||||
|
const isAuthStatusError = (error: any) => {
|
||||||
|
const status = error?.statusCode || error?.status || error?.response?.status
|
||||||
|
return status === 401 || status === 403
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => token.value, (newVal) => {
|
||||||
|
tokenState.value = newVal || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncUser = async (options: { redirectOnSuccess?: boolean; currentPath?: string } = {}) => {
|
||||||
|
const { redirectOnSuccess = false, currentPath } = options
|
||||||
|
const tokenVal = tokenState.value || token.value
|
||||||
|
if (!tokenVal || isSyncingUser.value) return
|
||||||
|
|
||||||
|
isSyncingUser.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetchCurrentUser()
|
||||||
|
if (res.ok) {
|
||||||
|
user.value = res.user
|
||||||
|
const path = currentPath ?? useNuxtApp().$router.currentRoute.value.path
|
||||||
|
|
||||||
|
if (res.user.must_change_password && path !== '/auth/change-password') {
|
||||||
|
await nuxtApp.runWithContext(() => navigateTo('/auth/change-password'))
|
||||||
|
} else if (redirectOnSuccess && path.startsWith('/auth')) {
|
||||||
|
await nuxtApp.runWithContext(() => navigateTo('/dashboard'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isAuthStatusError(error)) {
|
||||||
|
console.error('[Auth] Sync failed with authentication error, keeping session state:', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[Auth] Sync failed, logging out:', error)
|
||||||
|
logout()
|
||||||
|
} finally {
|
||||||
|
isSyncingUser.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoginSuccess = async (jwtToken: string) => {
|
||||||
|
token.value = jwtToken
|
||||||
|
tokenState.value = jwtToken
|
||||||
|
await syncUser({ redirectOnSuccess: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
token.value = null
|
||||||
|
tokenState.value = null
|
||||||
|
user.value = null
|
||||||
|
nuxtApp.runWithContext(() => navigateTo('/auth/login'))
|
||||||
|
}
|
||||||
|
|
||||||
const register = (body: RegisterCitizenRequest) =>
|
const register = (body: RegisterCitizenRequest) =>
|
||||||
useApiFetch<RegisterCitizenResponse>('/api/v1/auth/register', { method: 'POST', body })
|
useApiFetch<RegisterCitizenResponse>('/api/v1/auth/register', { method: 'POST', body })
|
||||||
|
|
||||||
@@ -23,6 +91,9 @@ export const useAuth = () => {
|
|||||||
const changePassword = (body: ChangePasswordRequest) =>
|
const changePassword = (body: ChangePasswordRequest) =>
|
||||||
useApiFetch<OkResponse>('/api/v1/auth/change-password', { method: 'POST', body })
|
useApiFetch<OkResponse>('/api/v1/auth/change-password', { method: 'POST', body })
|
||||||
|
|
||||||
|
const updateProfile = (body: UpdateProfileRequest) =>
|
||||||
|
useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'PATCH', body })
|
||||||
|
|
||||||
const fetchCurrentUser = () =>
|
const fetchCurrentUser = () =>
|
||||||
useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'GET' })
|
useApiFetch<CurrentUserResponse>('/api/v1/me', { method: 'GET' })
|
||||||
|
|
||||||
@@ -36,11 +107,21 @@ 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,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
changePassword,
|
changePassword,
|
||||||
|
updateProfile,
|
||||||
fetchCurrentUser,
|
fetchCurrentUser,
|
||||||
fetchRewards,
|
fetchRewards,
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AssignmentLinkResponse,
|
||||||
AssignTeamRequest,
|
AssignTeamRequest,
|
||||||
InterventionSummaryResponse,
|
InterventionSummaryResponse,
|
||||||
OkResponse,
|
OkResponse,
|
||||||
PagedReportListResponse,
|
PagedReportListResponse,
|
||||||
|
TeamListResponse,
|
||||||
UpdateStatusRequest
|
UpdateStatusRequest
|
||||||
} from "~/types/api";
|
} from "~/types/api";
|
||||||
|
|
||||||
@@ -16,6 +18,9 @@ export const useOperator = () => {
|
|||||||
const getOtherOperatorReports = (query?: { page?: number; per_page?: number }) =>
|
const getOtherOperatorReports = (query?: { page?: number; per_page?: number }) =>
|
||||||
useApiFetch<PagedReportListResponse>('/api/v1/operator/reports/others', { method: 'GET', query })
|
useApiFetch<PagedReportListResponse>('/api/v1/operator/reports/others', { method: 'GET', query })
|
||||||
|
|
||||||
|
const getTeams = () =>
|
||||||
|
useApiFetch<TeamListResponse>('/api/v1/operator/teams', { method: 'GET' })
|
||||||
|
|
||||||
const takeReport = (id: number) =>
|
const takeReport = (id: number) =>
|
||||||
useApiFetch<OkResponse>(`/api/v1/reports/${id}/take`, { method: 'POST' })
|
useApiFetch<OkResponse>(`/api/v1/reports/${id}/take`, { method: 'POST' })
|
||||||
|
|
||||||
@@ -28,13 +33,22 @@ export const useOperator = () => {
|
|||||||
const getInterventionSummary = (id: number) =>
|
const getInterventionSummary = (id: number) =>
|
||||||
useApiFetch<InterventionSummaryResponse>(`/api/v1/reports/${id}/intervention-summary`, { method: 'GET' })
|
useApiFetch<InterventionSummaryResponse>(`/api/v1/reports/${id}/intervention-summary`, { method: 'GET' })
|
||||||
|
|
||||||
|
const createInterventionLink = (id: number) =>
|
||||||
|
useApiFetch<AssignmentLinkResponse>(`/api/v1/reports/${id}/intervention-link`, { method: 'POST' })
|
||||||
|
|
||||||
|
const revokeInterventionLink = (id: number) =>
|
||||||
|
useApiFetch<OkResponse>(`/api/v1/reports/${id}/intervention-link`, { method: 'DELETE' })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getOpenReports,
|
getOpenReports,
|
||||||
getAssignedReports,
|
getAssignedReports,
|
||||||
getOtherOperatorReports,
|
getOtherOperatorReports,
|
||||||
|
getTeams,
|
||||||
takeReport,
|
takeReport,
|
||||||
updateReportStatus,
|
updateReportStatus,
|
||||||
assignTeam,
|
assignTeam,
|
||||||
getInterventionSummary,
|
getInterventionSummary,
|
||||||
|
createInterventionLink,
|
||||||
|
revokeInterventionLink,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
CategoryListResponse,
|
||||||
CreateReportRequest,
|
CreateReportRequest,
|
||||||
CreateReportResponse, FeedbackRequest, FlagRequest, FlagResponse,
|
CreateReportResponse, FeedbackRequest, FlagRequest, FlagResponse,
|
||||||
MediaUploadResponse,
|
MediaUploadResponse,
|
||||||
@@ -11,6 +12,9 @@ export const useReports = () => {
|
|||||||
const getReports = (query?: { area_id?: number; category_id?: number; status?: string }) =>
|
const getReports = (query?: { area_id?: number; category_id?: number; status?: string }) =>
|
||||||
useApiFetch<ReportListResponse>('/api/v1/reports', { method: 'GET', query })
|
useApiFetch<ReportListResponse>('/api/v1/reports', { method: 'GET', query })
|
||||||
|
|
||||||
|
const getCategories = () =>
|
||||||
|
useApiFetch<CategoryListResponse>('/api/v1/report-categories', { method: 'GET' })
|
||||||
|
|
||||||
const createReport = (body: CreateReportRequest) =>
|
const createReport = (body: CreateReportRequest) =>
|
||||||
useApiFetch<CreateReportResponse>('/api/v1/reports', { method: 'POST', body })
|
useApiFetch<CreateReportResponse>('/api/v1/reports', { method: 'POST', body })
|
||||||
|
|
||||||
@@ -37,6 +41,7 @@ export const useReports = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getReports,
|
getReports,
|
||||||
|
getCategories,
|
||||||
createReport,
|
createReport,
|
||||||
getReportDetail,
|
getReportDetail,
|
||||||
uploadMedia,
|
uploadMedia,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
// Se c'è un token ma l'utente non è ancora caricato e non stiamo già sincronizzando, sincronizza
|
||||||
|
if ((auth.token.value || auth.tokenState.value) && !auth.user.value && !auth.isSyncingUser.value) {
|
||||||
|
try {
|
||||||
|
await auth.syncUser({ currentPath: to.path })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth Middleware] Sync failed:', error)
|
||||||
|
return navigateTo('/auth/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se non autenticato, redirige al login
|
||||||
|
if (!auth.isAuthenticated.value) {
|
||||||
|
return navigateTo('/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica ruoli se richiesto
|
||||||
|
const requiredRole = to.meta.auth?.role
|
||||||
|
|
||||||
|
if (requiredRole && auth.user.value) {
|
||||||
|
const userRole = auth.user.value.role.toUpperCase()
|
||||||
|
|
||||||
|
const roleLevels: Record<string, number> = {
|
||||||
|
'CITIZEN': 1,
|
||||||
|
'OPERATOR': 2,
|
||||||
|
'ADMIN': 3
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLevel = roleLevels[userRole] || 0
|
||||||
|
const requiredLevel = roleLevels[requiredRole] || 0
|
||||||
|
|
||||||
|
if (userLevel < requiredLevel) {
|
||||||
|
console.warn(`[Auth] User role ${userRole} (level ${userLevel}) insufficient for required ${requiredRole} (level ${requiredLevel})`)
|
||||||
|
return navigateTo('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
const auth = useAuth()
|
||||||
|
|
||||||
|
if ((auth.token.value || auth.tokenState.value) && !auth.user.value && !auth.isSyncingUser.value) {
|
||||||
|
try {
|
||||||
|
await auth.syncUser({ currentPath: to.path })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Guest Middleware] Sync failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.isAuthenticated.value && auth.user.value) {
|
||||||
|
return navigateTo('/dashboard')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, nextTick, onBeforeUnmount, computed } from 'vue'
|
||||||
|
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
auth: { role: 'ADMIN' }
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Aree - Admin' })
|
||||||
|
|
||||||
|
const { getAreas, createArea, updateArea, deleteArea } = useAdmin()
|
||||||
|
const { data: areasRes, pending, refresh } = useAsyncData('admin-areas', () => getAreas())
|
||||||
|
|
||||||
|
const isCreating = ref(false)
|
||||||
|
const editingAreaId = ref<number | null>(null)
|
||||||
|
const formData = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
geojson: ''
|
||||||
|
})
|
||||||
|
const jsonError = ref('')
|
||||||
|
const actionError = ref('')
|
||||||
|
const deletingAreaId = ref<number | null>(null)
|
||||||
|
const areaPendingDelete = ref<any | null>(null)
|
||||||
|
|
||||||
|
// Map variables
|
||||||
|
let map: any = null
|
||||||
|
let polygon: any = null
|
||||||
|
const mapPoints = ref<[number, number][]>([])
|
||||||
|
|
||||||
|
const modalTitle = computed(() => editingAreaId.value ? 'Modifica Area' : 'Nuova Area')
|
||||||
|
const submitLabel = computed(() => editingAreaId.value ? 'Salva Modifiche' : 'Crea Area')
|
||||||
|
const isEditing = computed(() => editingAreaId.value !== null)
|
||||||
|
const areas = computed(() => areasRes.value?.areas || [])
|
||||||
|
const areaDeleteMessage = computed(() =>
|
||||||
|
areaPendingDelete.value ? `Vuoi eliminare area "${areaPendingDelete.value.name}"? Operazione irreversibile.` : ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
actionError.value = ''
|
||||||
|
editingAreaId.value = null
|
||||||
|
formData.name = ''
|
||||||
|
formData.description = ''
|
||||||
|
formData.geojson = ''
|
||||||
|
jsonError.value = ''
|
||||||
|
mapPoints.value = []
|
||||||
|
isCreating.value = true
|
||||||
|
|
||||||
|
nextTick(async () => {
|
||||||
|
// Import leaflet solo client-side
|
||||||
|
const L = (await import('leaflet')).default
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
map.remove()
|
||||||
|
map = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const bariBounds = L.latLngBounds(
|
||||||
|
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||||
|
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||||
|
)
|
||||||
|
|
||||||
|
map = L.map('areaMap', {
|
||||||
|
maxBounds: bariBounds,
|
||||||
|
maxBoundsViscosity: 1,
|
||||||
|
minZoom: 12
|
||||||
|
}).setView(BARI_CENTER, 12)
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap'
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
polygon = L.polygon([], { color: 'red' }).addTo(map)
|
||||||
|
map.fitBounds(bariBounds)
|
||||||
|
|
||||||
|
map.on('click', (e: any) => {
|
||||||
|
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
|
||||||
|
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonError.value = ''
|
||||||
|
mapPoints.value.push([e.latlng.lat, e.latlng.lng])
|
||||||
|
polygon.setLatLngs(mapPoints.value)
|
||||||
|
updateGeojson()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPolygonFromGeoJson = (boundaryGeojson: any) => {
|
||||||
|
const coordinates = boundaryGeojson?.coordinates?.[0]
|
||||||
|
if (!Array.isArray(coordinates)) {
|
||||||
|
mapPoints.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = coordinates
|
||||||
|
.slice(0, -1)
|
||||||
|
.map(([longitude, latitude]: [number, number]) => [latitude, longitude] as [number, number])
|
||||||
|
|
||||||
|
mapPoints.value = normalized
|
||||||
|
if (polygon) {
|
||||||
|
polygon.setLatLngs(mapPoints.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditModal = (area: any) => {
|
||||||
|
actionError.value = ''
|
||||||
|
editingAreaId.value = area.id
|
||||||
|
formData.name = area.name || ''
|
||||||
|
formData.description = area.description || ''
|
||||||
|
formData.geojson = area.boundary_geojson ? JSON.stringify(area.boundary_geojson, null, 2) : ''
|
||||||
|
jsonError.value = ''
|
||||||
|
mapPoints.value = []
|
||||||
|
isCreating.value = true
|
||||||
|
|
||||||
|
nextTick(async () => {
|
||||||
|
const L = (await import('leaflet')).default
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
map.remove()
|
||||||
|
map = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const bariBounds = L.latLngBounds(
|
||||||
|
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||||
|
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||||
|
)
|
||||||
|
|
||||||
|
map = L.map('areaMap', {
|
||||||
|
maxBounds: bariBounds,
|
||||||
|
maxBoundsViscosity: 1,
|
||||||
|
minZoom: 12
|
||||||
|
}).setView(BARI_CENTER, 12)
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap'
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
polygon = L.polygon([], { color: 'red' }).addTo(map)
|
||||||
|
map.fitBounds(bariBounds)
|
||||||
|
loadPolygonFromGeoJson(area.boundary_geojson)
|
||||||
|
|
||||||
|
map.on('click', (e: any) => {
|
||||||
|
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
|
||||||
|
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonError.value = ''
|
||||||
|
mapPoints.value.push([e.latlng.lat, e.latlng.lng])
|
||||||
|
polygon.setLatLngs(mapPoints.value)
|
||||||
|
updateGeojson()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearMap = () => {
|
||||||
|
mapPoints.value = []
|
||||||
|
if (polygon) {
|
||||||
|
polygon.setLatLngs([])
|
||||||
|
}
|
||||||
|
formData.geojson = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateGeojson = () => {
|
||||||
|
if (mapPoints.value.length < 3) {
|
||||||
|
formData.geojson = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number]) // Lng, Lat
|
||||||
|
if (coords[0]) {
|
||||||
|
coords.push([...coords[0]]) // Chiudi poligono
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.geojson = JSON.stringify({
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [coords]
|
||||||
|
}, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCreateModal = () => {
|
||||||
|
isCreating.value = false
|
||||||
|
editingAreaId.value = null
|
||||||
|
if (map) {
|
||||||
|
map.remove()
|
||||||
|
map = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (map) map.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
jsonError.value = ''
|
||||||
|
let parsedGeojson = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedGeojson = JSON.parse(formData.geojson)
|
||||||
|
if (!parsedGeojson.type || !parsedGeojson.coordinates) {
|
||||||
|
throw new Error('GeoJSON non valido. Richiesto "type" e "coordinates".')
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates = parsedGeojson.coordinates?.[0] || []
|
||||||
|
const outsideBari = coordinates.some(([longitude, latitude]: [number, number]) => !isWithinBariBounds(latitude, longitude))
|
||||||
|
if (outsideBari) {
|
||||||
|
throw new Error('Area non valida. Tutti i punti devono essere nel Comune di Bari.')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
jsonError.value = e.message || 'Formato JSON non valido.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
boundary_geojson: parsedGeojson
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = editingAreaId.value
|
||||||
|
? await updateArea(editingAreaId.value, payload)
|
||||||
|
: await createArea(payload)
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
closeCreateModal()
|
||||||
|
actionError.value = ''
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save area', err)
|
||||||
|
actionError.value = 'Errore durante il salvataggio dell\'area.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const askDeleteArea = (area: any) => {
|
||||||
|
areaPendingDelete.value = area
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDeleteAreaModal = () => {
|
||||||
|
if (deletingAreaId.value) return
|
||||||
|
areaPendingDelete.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteArea = async () => {
|
||||||
|
if (!areaPendingDelete.value) return
|
||||||
|
|
||||||
|
const area = areaPendingDelete.value
|
||||||
|
|
||||||
|
deletingAreaId.value = area.id
|
||||||
|
actionError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteArea(area.id)
|
||||||
|
if (res.ok) {
|
||||||
|
if (editingAreaId.value === area.id) {
|
||||||
|
closeCreateModal()
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
areaPendingDelete.value = null
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to delete area', err)
|
||||||
|
actionError.value = err?.data?.message || 'Errore durante eliminazione area.'
|
||||||
|
} finally {
|
||||||
|
deletingAreaId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 font-weight-bold mb-0">Gestione Aree</h1>
|
||||||
|
<div>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Torna al pannello admin</NuxtLink>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="openCreateModal">
|
||||||
|
<BIIcon name="it-plus" size="xs" class="me-1" />
|
||||||
|
Nuova Area
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="areas.length === 0" class="alert alert-info">
|
||||||
|
Nessuna area configurata.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="actionError" class="alert alert-danger">
|
||||||
|
{{ actionError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle bg-white shadow-sm rounded">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Descrizione</th>
|
||||||
|
<th>GeoJSON</th>
|
||||||
|
<th class="text-end">Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="area in areas" :key="area.id">
|
||||||
|
<td>{{ area.id }}</td>
|
||||||
|
<td><strong>{{ area.name }}</strong></td>
|
||||||
|
<td>{{ area.description || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="area.boundary_geojson" class="badge border border-primary text-primary">Configurato</span>
|
||||||
|
<span v-else class="badge border border-danger text-danger">Mancante</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-2" @click="openEditModal(area)">
|
||||||
|
Modifica
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-danger ms-2"
|
||||||
|
:disabled="deletingAreaId === area.id"
|
||||||
|
@click="askDeleteArea(area)"
|
||||||
|
>
|
||||||
|
<span v-if="deletingAreaId === area.id" class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modale Creazione -->
|
||||||
|
<div v-if="isCreating" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
|
||||||
|
<div v-if="isCreating" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ modalTitle }}</h5>
|
||||||
|
<button type="button" class="btn-close" @click="closeCreateModal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form @submit.prevent="handleCreate">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Nome Area</label>
|
||||||
|
<input type="text" class="form-control" v-model="formData.name" required placeholder="Es. Centro Storico" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Descrizione (opzionale)</label>
|
||||||
|
<textarea class="form-control" v-model="formData.description" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">GeoJSON Generato</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control font-monospace text-sm"
|
||||||
|
rows="6"
|
||||||
|
v-model="formData.geojson"
|
||||||
|
required
|
||||||
|
readonly
|
||||||
|
placeholder='{ "type": "Polygon", "coordinates": [...] }'
|
||||||
|
></textarea>
|
||||||
|
<div v-if="jsonError" class="form-text text-danger mt-1">{{ jsonError }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="mb-2 d-flex justify-content-between align-items-center">
|
||||||
|
<label class="form-label mb-0">Disegna Poligono sulla Mappa</label>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm" @click="clearMap">
|
||||||
|
Pulisci Mappa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-light border small mb-2 p-2">
|
||||||
|
Clicca sulla mappa per aggiungere vertici al poligono. Sono necessari almeno 3 punti. Sono ammessi solo punti nel Comune di Bari.
|
||||||
|
</div>
|
||||||
|
<div id="areaMap" class="rounded border" style="height: 400px; width: 100%; z-index: 1;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer d-flex justify-content-end gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="closeCreateModal">Annulla</button>
|
||||||
|
<button type="button" class="btn btn-primary" :disabled="!formData.name || !formData.geojson" @click="handleCreate">
|
||||||
|
{{ submitLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
:open="areaPendingDelete !== null"
|
||||||
|
title="Elimina area"
|
||||||
|
:message="areaDeleteMessage"
|
||||||
|
confirm-label="Elimina area"
|
||||||
|
confirm-class="btn-danger"
|
||||||
|
:pending="deletingAreaId !== null"
|
||||||
|
@cancel="closeDeleteAreaModal"
|
||||||
|
@confirm="handleDeleteArea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#areaMap {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
margin-block: 0!important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Category, Team } from '~/types/api'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
auth: { role: 'ADMIN' }
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Categorie e Team - Admin' })
|
||||||
|
|
||||||
|
const { createCategory, updateCategory, deleteCategory, getTeams, createTeam, updateTeam, deleteTeam, getAreas } = useAdmin()
|
||||||
|
const { getCategories } = useReports()
|
||||||
|
|
||||||
|
const { data: categoriesRes, refresh: refreshCategories } = await useAsyncData('report-categories-admin-view', () => getCategories())
|
||||||
|
const { data: teamsRes, refresh: refreshTeams } = await useAsyncData('admin-teams', () => getTeams())
|
||||||
|
const { data: areasRes } = await useAsyncData('admin-team-areas', () => getAreas())
|
||||||
|
|
||||||
|
const categoryForm = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const editingCategoryId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const teamForm = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
team_category_id: null as number | null,
|
||||||
|
area_id: null as number | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const editingTeamId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const isSavingCategory = ref(false)
|
||||||
|
const isSavingTeam = ref(false)
|
||||||
|
const categoryError = ref('')
|
||||||
|
const teamError = ref('')
|
||||||
|
const deletingCategoryId = ref<number | null>(null)
|
||||||
|
const deletingTeamId = ref<number | null>(null)
|
||||||
|
const categoryPendingDelete = ref<Category | null>(null)
|
||||||
|
const teamPendingDelete = ref<Team | null>(null)
|
||||||
|
|
||||||
|
const categories = computed(() => categoriesRes.value?.categories || [])
|
||||||
|
const teams = computed(() => teamsRes.value?.teams || [])
|
||||||
|
const areas = computed(() => areasRes.value?.areas || [])
|
||||||
|
const categoryDeleteMessage = computed(() =>
|
||||||
|
categoryPendingDelete.value ? `Vuoi eliminare categoria "${categoryPendingDelete.value.name}"? Operazione irreversibile.` : ''
|
||||||
|
)
|
||||||
|
const teamDeleteMessage = computed(() =>
|
||||||
|
teamPendingDelete.value ? `Vuoi eliminare team "${teamPendingDelete.value.name}"? Operazione irreversibile.` : ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetCategoryForm = () => {
|
||||||
|
editingCategoryId.value = null
|
||||||
|
categoryForm.name = ''
|
||||||
|
categoryForm.description = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetTeamForm = () => {
|
||||||
|
editingTeamId.value = null
|
||||||
|
teamForm.name = ''
|
||||||
|
teamForm.description = ''
|
||||||
|
teamForm.team_category_id = null
|
||||||
|
teamForm.area_id = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const editCategory = (category: Category) => {
|
||||||
|
editingCategoryId.value = category.id
|
||||||
|
categoryForm.name = category.name
|
||||||
|
categoryForm.description = category.description || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const editTeam = (team: Team) => {
|
||||||
|
editingTeamId.value = team.id
|
||||||
|
teamForm.name = team.name
|
||||||
|
teamForm.description = team.description || ''
|
||||||
|
teamForm.team_category_id = team.team_category_id
|
||||||
|
teamForm.area_id = team.area_id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCategory = async () => {
|
||||||
|
categoryError.value = ''
|
||||||
|
isSavingCategory.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: categoryForm.name.trim(),
|
||||||
|
description: categoryForm.description.trim() || undefined,
|
||||||
|
}
|
||||||
|
if (editingCategoryId.value) {
|
||||||
|
await updateCategory(editingCategoryId.value, payload)
|
||||||
|
} else {
|
||||||
|
await createCategory(payload)
|
||||||
|
}
|
||||||
|
resetCategoryForm()
|
||||||
|
await refreshCategories()
|
||||||
|
} catch (error: any) {
|
||||||
|
categoryError.value = error?.data?.message || 'Creazione categoria non riuscita.'
|
||||||
|
} finally {
|
||||||
|
isSavingCategory.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitTeam = async () => {
|
||||||
|
if (!teamForm.team_category_id) return
|
||||||
|
|
||||||
|
teamError.value = ''
|
||||||
|
isSavingTeam.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: teamForm.name.trim(),
|
||||||
|
description: teamForm.description.trim() || undefined,
|
||||||
|
team_category_id: teamForm.team_category_id,
|
||||||
|
area_id: teamForm.area_id || undefined,
|
||||||
|
}
|
||||||
|
if (editingTeamId.value) {
|
||||||
|
await updateTeam(editingTeamId.value, payload)
|
||||||
|
} else {
|
||||||
|
await createTeam(payload)
|
||||||
|
}
|
||||||
|
resetTeamForm()
|
||||||
|
await refreshTeams()
|
||||||
|
} catch (error: any) {
|
||||||
|
teamError.value = error?.data?.message || 'Creazione team non riuscita.'
|
||||||
|
} finally {
|
||||||
|
isSavingTeam.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCategoryActive = async (category: Category) => {
|
||||||
|
try {
|
||||||
|
await updateCategory(category.id, { active: category.active === false })
|
||||||
|
await refreshCategories()
|
||||||
|
} catch (error: any) {
|
||||||
|
categoryError.value = error?.data?.message || 'Aggiornamento categoria non riuscito.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const askDeleteCategory = (category: Category) => {
|
||||||
|
categoryPendingDelete.value = category
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDeleteCategoryModal = () => {
|
||||||
|
if (deletingCategoryId.value) return
|
||||||
|
categoryPendingDelete.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteCategory = async () => {
|
||||||
|
if (!categoryPendingDelete.value) return
|
||||||
|
|
||||||
|
const category = categoryPendingDelete.value
|
||||||
|
|
||||||
|
deletingCategoryId.value = category.id
|
||||||
|
categoryError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteCategory(category.id)
|
||||||
|
if (editingCategoryId.value === category.id) {
|
||||||
|
resetCategoryForm()
|
||||||
|
}
|
||||||
|
await refreshCategories()
|
||||||
|
categoryPendingDelete.value = null
|
||||||
|
} catch (error: any) {
|
||||||
|
categoryError.value = error?.data?.message || 'Eliminazione categoria non riuscita.'
|
||||||
|
} finally {
|
||||||
|
deletingCategoryId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTeamActive = async (team: Team) => {
|
||||||
|
try {
|
||||||
|
await updateTeam(team.id, { active: team.active === false })
|
||||||
|
await refreshTeams()
|
||||||
|
} catch (error: any) {
|
||||||
|
teamError.value = error?.data?.message || 'Aggiornamento team non riuscito.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const askDeleteTeam = (team: Team) => {
|
||||||
|
teamPendingDelete.value = team
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDeleteTeamModal = () => {
|
||||||
|
if (deletingTeamId.value) return
|
||||||
|
teamPendingDelete.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTeam = async () => {
|
||||||
|
if (!teamPendingDelete.value) return
|
||||||
|
|
||||||
|
const team = teamPendingDelete.value
|
||||||
|
|
||||||
|
deletingTeamId.value = team.id
|
||||||
|
teamError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteTeam(team.id)
|
||||||
|
if (editingTeamId.value === team.id) {
|
||||||
|
resetTeamForm()
|
||||||
|
}
|
||||||
|
await refreshTeams()
|
||||||
|
teamPendingDelete.value = null
|
||||||
|
} catch (error: any) {
|
||||||
|
teamError.value = error?.data?.message || 'Eliminazione team non riuscita.'
|
||||||
|
} finally {
|
||||||
|
deletingTeamId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 font-weight-bold mb-1">Categorie e Team</h1>
|
||||||
|
<p class="text-muted mb-0">Gestione dati di riferimento per segnalazioni e interventi.</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm">Torna al pannello admin</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-12 col-xl-5">
|
||||||
|
<div class="card card-bg border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<h2 class="h5 mb-0">Categorie</h2>
|
||||||
|
<span class="badge bg-light text-dark border">{{ categories.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="categoryError" class="alert alert-danger py-2" role="alert">
|
||||||
|
{{ categoryError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mb-4" @submit.prevent="submitCategory">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Nome categoria</label>
|
||||||
|
<input v-model="categoryForm.name" type="text" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Descrizione</label>
|
||||||
|
<textarea v-model="categoryForm.description" class="form-control" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="isSavingCategory || !categoryForm.name.trim()">
|
||||||
|
<span v-if="isSavingCategory" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
{{ editingCategoryId ? 'Salva categoria' : 'Crea categoria' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="editingCategoryId"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary ms-2"
|
||||||
|
@click="resetCategoryForm"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-responsive report-table-wrapper">
|
||||||
|
<table class="table table-hover report-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th class="text-end">Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="category in categories" :key="category.id">
|
||||||
|
<td class="report-table-id">#{{ category.id }}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ category.name }}</strong>
|
||||||
|
<div v-if="category.description" class="small text-muted mt-1">{{ category.description }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="category.active === false ? 'bg-secondary' : 'bg-success'">
|
||||||
|
{{ category.active === false ? 'Inattiva' : 'Attiva' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-2" @click="editCategory(category)">
|
||||||
|
Modifica
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="category.active === false ? 'btn-outline-success' : 'btn-outline-secondary'"
|
||||||
|
@click="toggleCategoryActive(category)"
|
||||||
|
>
|
||||||
|
{{ category.active === false ? 'Attiva' : 'Disattiva' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-danger ms-2"
|
||||||
|
:disabled="deletingCategoryId === category.id"
|
||||||
|
@click="askDeleteCategory(category)"
|
||||||
|
>
|
||||||
|
<span v-if="deletingCategoryId === category.id" class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="categories.length === 0">
|
||||||
|
<td colspan="4" class="text-muted">Nessuna categoria configurata.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-xl-7">
|
||||||
|
<div class="card card-bg border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<h2 class="h5 mb-0">Team di intervento</h2>
|
||||||
|
<span class="badge bg-light text-dark border">{{ teams.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="teamError" class="alert alert-danger py-2" role="alert">
|
||||||
|
{{ teamError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="row g-3 mb-4" @submit.prevent="submitTeam">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">Nome team</label>
|
||||||
|
<input v-model="teamForm.name" type="text" class="form-control" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">Categoria coperta</label>
|
||||||
|
<select v-model.number="teamForm.team_category_id" class="form-select" required>
|
||||||
|
<option :value="null">Seleziona categoria</option>
|
||||||
|
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||||
|
{{ category.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">Area</label>
|
||||||
|
<select v-model.number="teamForm.area_id" class="form-select">
|
||||||
|
<option :value="null">Tutte le aree</option>
|
||||||
|
<option v-for="area in areas" :key="area.id" :value="area.id">
|
||||||
|
{{ area.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">Descrizione</label>
|
||||||
|
<input v-model="teamForm.description" type="text" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="isSavingTeam || !teamForm.name.trim() || !teamForm.team_category_id">
|
||||||
|
<span v-if="isSavingTeam" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
{{ editingTeamId ? 'Salva team' : 'Crea team' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="editingTeamId"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary ms-2"
|
||||||
|
@click="resetTeamForm"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-responsive report-table-wrapper">
|
||||||
|
<table class="table table-hover report-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Team</th>
|
||||||
|
<th>Categoria</th>
|
||||||
|
<th>Area</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th class="text-end">Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="team in teams" :key="team.id">
|
||||||
|
<td class="report-table-id">#{{ team.id }}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ team.name }}</strong>
|
||||||
|
<div v-if="team.description" class="small text-muted mt-1">{{ team.description }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ team.category_name || '-' }}</td>
|
||||||
|
<td>{{ team.area_name || 'Tutte' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="team.active === false ? 'bg-secondary' : 'bg-success'">
|
||||||
|
{{ team.active === false ? 'Inattivo' : 'Attivo' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-2" @click="editTeam(team)">
|
||||||
|
Modifica
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="team.active === false ? 'btn-outline-success' : 'btn-outline-secondary'"
|
||||||
|
@click="toggleTeamActive(team)"
|
||||||
|
>
|
||||||
|
{{ team.active === false ? 'Attiva' : 'Disattiva' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-danger ms-2"
|
||||||
|
:disabled="deletingTeamId === team.id"
|
||||||
|
@click="askDeleteTeam(team)"
|
||||||
|
>
|
||||||
|
<span v-if="deletingTeamId === team.id" class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
Elimina
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="teams.length === 0">
|
||||||
|
<td colspan="6" class="text-muted">Nessun team configurato.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
:open="categoryPendingDelete !== null"
|
||||||
|
title="Elimina categoria"
|
||||||
|
:message="categoryDeleteMessage"
|
||||||
|
confirm-label="Elimina categoria"
|
||||||
|
confirm-class="btn-danger"
|
||||||
|
:pending="deletingCategoryId !== null"
|
||||||
|
@cancel="closeDeleteCategoryModal"
|
||||||
|
@confirm="handleDeleteCategory"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
:open="teamPendingDelete !== null"
|
||||||
|
title="Elimina team"
|
||||||
|
:message="teamDeleteMessage"
|
||||||
|
confirm-label="Elimina team"
|
||||||
|
confirm-class="btn-danger"
|
||||||
|
:pending="deletingTeamId !== null"
|
||||||
|
@cancel="closeDeleteTeamModal"
|
||||||
|
@confirm="handleDeleteTeam"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
auth: { role: 'ADMIN' }
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Moderazione Abusi - Admin' })
|
||||||
|
|
||||||
|
const { getModerationFlags, updateModerationFlag } = useAdmin()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const actionError = ref('')
|
||||||
|
const resolveModal = reactive({
|
||||||
|
open: false,
|
||||||
|
flagId: null as number | null,
|
||||||
|
rejectReport: false,
|
||||||
|
note: '',
|
||||||
|
pending: false,
|
||||||
|
})
|
||||||
|
const dismissModal = reactive({
|
||||||
|
open: false,
|
||||||
|
flagId: null as number | null,
|
||||||
|
pending: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: flagsRes, pending, refresh } = useAsyncData(
|
||||||
|
'admin-moderation-flags',
|
||||||
|
() => getModerationFlags({ page: page.value, per_page: perPage.value, status: statusFilter.value || undefined }),
|
||||||
|
{ watch: [page, statusFilter] }
|
||||||
|
)
|
||||||
|
|
||||||
|
const openResolveModal = (id: number, rejectReport: boolean) => {
|
||||||
|
resolveModal.open = true
|
||||||
|
resolveModal.flagId = id
|
||||||
|
resolveModal.rejectReport = rejectReport
|
||||||
|
resolveModal.note = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeResolveModal = () => {
|
||||||
|
if (resolveModal.pending) return
|
||||||
|
resolveModal.open = false
|
||||||
|
resolveModal.flagId = null
|
||||||
|
resolveModal.note = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolve = async () => {
|
||||||
|
if (!resolveModal.flagId) return
|
||||||
|
|
||||||
|
resolveModal.pending = true
|
||||||
|
actionError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateModerationFlag(resolveModal.flagId, {
|
||||||
|
status: 'RESOLVED',
|
||||||
|
reject_report: resolveModal.rejectReport,
|
||||||
|
resolution_note: resolveModal.note || undefined
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
closeResolveModal()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to resolve flag', err)
|
||||||
|
actionError.value = 'Errore durante la risoluzione della segnalazione.'
|
||||||
|
} finally {
|
||||||
|
resolveModal.pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDismissModal = (id: number) => {
|
||||||
|
dismissModal.open = true
|
||||||
|
dismissModal.flagId = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDismissModal = () => {
|
||||||
|
if (dismissModal.pending) return
|
||||||
|
dismissModal.open = false
|
||||||
|
dismissModal.flagId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismiss = async () => {
|
||||||
|
if (!dismissModal.flagId) return
|
||||||
|
|
||||||
|
dismissModal.pending = true
|
||||||
|
actionError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateModerationFlag(dismissModal.flagId, { status: 'DISMISSED' })
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
closeDismissModal()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to dismiss flag', err)
|
||||||
|
actionError.value = 'Errore durante l\'aggiornamento.'
|
||||||
|
} finally {
|
||||||
|
dismissModal.pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString('it-IT', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 font-weight-bold mb-0">Moderazione Abusi</h1>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm">Torna al pannello admin</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Filtra per Stato</label>
|
||||||
|
<select class="form-select" v-model="statusFilter">
|
||||||
|
<option value="">Tutti</option>
|
||||||
|
<option value="OPEN">Aperti</option>
|
||||||
|
<option value="RESOLVED">Risolti</option>
|
||||||
|
<option value="DISMISSED">Ignorati</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-danger" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!flagsRes?.flags || flagsRes.flags.length === 0" class="alert alert-success">
|
||||||
|
Nessun elemento da moderare al momento.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="actionError" class="alert alert-danger">
|
||||||
|
{{ actionError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div class="card mb-3 shadow-sm border-0" v-for="flag in flagsRes.flags" :key="flag.id">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>Flag #{{ flag.id }}</strong> -
|
||||||
|
<span class="badge" :class="{
|
||||||
|
'bg-warning': flag.status === 'OPEN',
|
||||||
|
'bg-success': flag.status === 'RESOLVED',
|
||||||
|
'bg-secondary': flag.status === 'DISMISSED'
|
||||||
|
}">{{ flag.status }}</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ formatDate(flag.created_at) }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h5 class="card-title">Motivo: {{ flag.reason }}</h5>
|
||||||
|
<p class="card-text text-muted mb-2">
|
||||||
|
{{ flag.details || 'Nessun dettaglio aggiuntivo fornito.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<span class="badge bg-info me-2">{{ flag.content_type }}</span>
|
||||||
|
<span v-if="flag.report_id">
|
||||||
|
Rif:
|
||||||
|
<NuxtLink :to="`/operator/reports/${flag.report_id}`" class="text-decoration-none">
|
||||||
|
Segnalazione #{{ flag.report_id }}
|
||||||
|
</NuxtLink>
|
||||||
|
</span>
|
||||||
|
<span v-if="flag.message_id" class="ms-2">
|
||||||
|
| Messaggio #{{ flag.message_id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 small">
|
||||||
|
Segnalato da: {{ flag.created_by_email }} (ID: {{ flag.created_by_user_id }})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="flag.status !== 'OPEN'" class="mt-2 small text-muted border-top pt-2">
|
||||||
|
<strong>Risolto/Ignorato da:</strong> {{ flag.resolved_by_email }} in data {{ flag.resolved_at ? formatDate(flag.resolved_at) : 'N/D' }}
|
||||||
|
<div v-if="flag.resolution_note"><strong>Nota:</strong> {{ flag.resolution_note }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4 text-end" v-if="flag.status === 'OPEN'">
|
||||||
|
<div class="d-flex flex-column gap-2 mt-3 mt-md-0">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" @click="openResolveModal(flag.id, true)">
|
||||||
|
<BIIcon name="it-ban" size="xs" /> Risolvi & Rigetta Segn.
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-warning btn-sm" @click="openResolveModal(flag.id, false)">
|
||||||
|
<BIIcon name="it-check" size="xs" /> Risolvi (Solo Avviso)
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @click="openDismissModal(flag.id)">
|
||||||
|
<BIIcon name="it-close" size="xs" /> Ignora Flag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
v-if="(flagsRes.total || flagsRes.flags.length) > perPage"
|
||||||
|
:current-page="page"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-items="flagsRes.total || (page * perPage + 1)"
|
||||||
|
@page-change="page = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="resolveModal.open" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
|
||||||
|
<div v-if="resolveModal.open" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ resolveModal.rejectReport ? 'Risolvi e rigetta segnalazione' : 'Risolvi flag' }}</h5>
|
||||||
|
<button type="button" class="btn-close" :disabled="resolveModal.pending" @click="closeResolveModal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="form-label">Nota di risoluzione</label>
|
||||||
|
<textarea v-model="resolveModal.note" class="form-control" rows="4" placeholder="Nota opzionale"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" :disabled="resolveModal.pending" @click="closeResolveModal">Annulla</button>
|
||||||
|
<button type="button" class="btn btn-primary" :disabled="resolveModal.pending" @click="handleResolve">
|
||||||
|
<span v-if="resolveModal.pending" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Conferma
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
:open="dismissModal.open"
|
||||||
|
title="Ignora flag"
|
||||||
|
message="Vuoi ignorare questo flag? Azione registrata in moderazione."
|
||||||
|
confirm-label="Ignora flag"
|
||||||
|
confirm-class="btn-outline-secondary"
|
||||||
|
:pending="dismissModal.pending"
|
||||||
|
@cancel="closeDismissModal"
|
||||||
|
@confirm="handleDismiss"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
auth: { role: 'ADMIN' }
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Gestione Utenti - Admin' })
|
||||||
|
|
||||||
|
const { getUsers, updateUserBan, updateUserRoles } = useAdmin()
|
||||||
|
|
||||||
|
const { data: usersRes, pending, refresh } = useAsyncData('admin-users', () => getUsers())
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const banError = ref('')
|
||||||
|
const roleError = ref('')
|
||||||
|
const pendingBanUser = ref<any | null>(null)
|
||||||
|
const isUpdatingBan = ref(false)
|
||||||
|
const parseRoles = (roles?: string) => roles ? roles.split(',').map((r) => r.trim()).filter(Boolean) : []
|
||||||
|
const banConfirmMessage = computed(() =>
|
||||||
|
pendingBanUser.value
|
||||||
|
? `Vuoi ${pendingBanUser.value.banned ? 'sbloccare' : 'bannare'} utente ${pendingBanUser.value.first_name} ${pendingBanUser.value.last_name}?`
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
const banConfirmLabel = computed(() => pendingBanUser.value?.banned ? 'Sblocca utente' : 'Banna utente')
|
||||||
|
const banConfirmClass = computed(() => pendingBanUser.value?.banned ? 'btn-success' : 'btn-danger')
|
||||||
|
|
||||||
|
const filteredUsers = computed(() => {
|
||||||
|
if (!usersRes.value?.users) return []
|
||||||
|
if (!search.value) return usersRes.value.users
|
||||||
|
const s = search.value.toLowerCase()
|
||||||
|
return usersRes.value.users.filter(u =>
|
||||||
|
u.first_name.toLowerCase().includes(s) ||
|
||||||
|
u.last_name.toLowerCase().includes(s) ||
|
||||||
|
u.email.toLowerCase().includes(s) ||
|
||||||
|
u.fiscal_code?.toLowerCase().includes(s)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const askToggleBan = (user: any) => {
|
||||||
|
pendingBanUser.value = user
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBanModal = () => {
|
||||||
|
if (isUpdatingBan.value) return
|
||||||
|
pendingBanUser.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleBan = async () => {
|
||||||
|
if (!pendingBanUser.value) return
|
||||||
|
|
||||||
|
const user = pendingBanUser.value
|
||||||
|
isUpdatingBan.value = true
|
||||||
|
banError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateUserBan(user.id, { banned: !user.banned })
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
pendingBanUser.value = null
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update user ban status', err)
|
||||||
|
banError.value = 'Errore durante l\'aggiornamento dello stato di ban.'
|
||||||
|
} finally {
|
||||||
|
isUpdatingBan.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal per i ruoli
|
||||||
|
const editingUserId = ref<number | null>(null)
|
||||||
|
const selectedRoles = ref<string[]>([])
|
||||||
|
const availableRoles = ['CITIZEN', 'OPERATOR', 'ADMIN']
|
||||||
|
|
||||||
|
const openRoleModal = (user: any) => {
|
||||||
|
editingUserId.value = user.id
|
||||||
|
selectedRoles.value = parseRoles(user.roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeRoleModal = () => {
|
||||||
|
editingUserId.value = null
|
||||||
|
selectedRoles.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveRoles = async () => {
|
||||||
|
if (!editingUserId.value) return
|
||||||
|
|
||||||
|
roleError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await updateUserRoles(editingUserId.value, { roles: selectedRoles.value })
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
closeRoleModal()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update roles', err)
|
||||||
|
roleError.value = 'Errore durante l\'aggiornamento dei ruoli.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 font-weight-bold mb-0">Gestione Utenti</h1>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm">Torna al pannello admin</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" placeholder="Cerca per nome, email o CF..." v-model="search">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!usersRes?.users || usersRes.users.length === 0" class="alert alert-info">
|
||||||
|
Nessun utente trovato.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="banError" class="alert alert-danger">
|
||||||
|
{{ banError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="roleError" class="alert alert-danger">
|
||||||
|
{{ roleError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Email / CF</th>
|
||||||
|
<th>Ruoli</th>
|
||||||
|
<th>Punti</th>
|
||||||
|
<th>Stato</th>
|
||||||
|
<th class="text-end">Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in filteredUsers" :key="user.id">
|
||||||
|
<td>{{ user.id }}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ user.first_name }} {{ user.last_name }}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>{{ user.email }}</div>
|
||||||
|
<small class="text-muted">{{ user.fiscal_code }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-for="r in parseRoles(user.roles)" :key="r" class="badge bg-primary me-1">
|
||||||
|
{{ r }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ user.points || 0 }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="user.banned" class="badge bg-danger">Bannato</span>
|
||||||
|
<span v-else class="badge bg-success">Attivo</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-primary me-2"
|
||||||
|
@click="openRoleModal(user)"
|
||||||
|
>
|
||||||
|
Ruoli
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="user.banned ? 'btn-outline-success' : 'btn-outline-danger'"
|
||||||
|
@click="askToggleBan(user)"
|
||||||
|
>
|
||||||
|
{{ user.banned ? 'Sblocca' : 'Banna' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="editingUserId !== null" class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Modifica Ruoli Utente</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" @click="closeRoleModal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-check mb-2" v-for="role in availableRoles" :key="role">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
:value="role"
|
||||||
|
:id="'role-'+role"
|
||||||
|
:checked="selectedRoles.includes(role)"
|
||||||
|
v-model="selectedRoles"
|
||||||
|
>
|
||||||
|
<label class="form-check-label" :for="'role-'+role">
|
||||||
|
{{ role }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="closeRoleModal">Annulla</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="saveRoles">Salva</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
:open="pendingBanUser !== null"
|
||||||
|
title="Conferma stato utente"
|
||||||
|
:message="banConfirmMessage"
|
||||||
|
:confirm-label="banConfirmLabel"
|
||||||
|
:confirm-class="banConfirmClass"
|
||||||
|
:pending="isUpdatingBan"
|
||||||
|
@cancel="closeBanModal"
|
||||||
|
@confirm="handleToggleBan"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Cambio Password - Visione' })
|
||||||
|
|
||||||
|
const { changePassword, user, syncUser } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
current_password: '',
|
||||||
|
new_password: '',
|
||||||
|
confirm_password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const showCurrent = ref(false)
|
||||||
|
const showNew = ref(false)
|
||||||
|
const showConfirm = ref(false)
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
return (
|
||||||
|
formData.current_password.length > 0 &&
|
||||||
|
formData.new_password.length >= 8 &&
|
||||||
|
formData.new_password === formData.confirm_password
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await changePassword({
|
||||||
|
current_password: formData.current_password,
|
||||||
|
new_password: formData.new_password
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await syncUser()
|
||||||
|
await router.push('/profile')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage.value = error.data?.message || 'Errore. Verifica la password attuale.'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-lg-8 col-xl-6">
|
||||||
|
<div v-if="user?.must_change_password" class="callout callout-highlight callout-warning mb-4">
|
||||||
|
<div class="callout-title d-flex align-items-center">
|
||||||
|
<BIIcon name="it-warning-circle" size="sm" color="warning" class="me-2" />
|
||||||
|
Azione richiesta
|
||||||
|
</div>
|
||||||
|
<p class="mb-0">Per motivi di sicurezza, imposta una nuova password per il tuo account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bg border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4 p-lg-5">
|
||||||
|
<h1 class="h3 mb-3">Cambia password</h1>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Aggiorna credenziali prima di continuare. Password nuova deve essere diversa da quella attuale.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="d-grid gap-4">
|
||||||
|
<div class="password-field">
|
||||||
|
<label for="current_password" class="form-label">Password attuale</label>
|
||||||
|
<div class="password-field-control">
|
||||||
|
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
|
||||||
|
<input
|
||||||
|
id="current_password"
|
||||||
|
v-model="formData.current_password"
|
||||||
|
:type="showCurrent ? 'text' : 'password'"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="current-password"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-password-toggle"
|
||||||
|
:aria-label="showCurrent ? 'Nascondi password attuale' : 'Mostra password attuale'"
|
||||||
|
@click="showCurrent = !showCurrent"
|
||||||
|
>
|
||||||
|
<BIIcon :name="showCurrent ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="password-field">
|
||||||
|
<label for="new_password" class="form-label">Nuova password</label>
|
||||||
|
<div class="password-field-control">
|
||||||
|
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
|
||||||
|
<input
|
||||||
|
id="new_password"
|
||||||
|
v-model="formData.new_password"
|
||||||
|
:type="showNew ? 'text' : 'password'"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="new-password"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-password-toggle"
|
||||||
|
:aria-label="showNew ? 'Nascondi nuova password' : 'Mostra nuova password'"
|
||||||
|
@click="showNew = !showNew"
|
||||||
|
>
|
||||||
|
<BIIcon :name="showNew ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Minimo 8 caratteri.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="password-field">
|
||||||
|
<label for="confirm_password" class="form-label">Conferma nuova password</label>
|
||||||
|
<div class="password-field-control">
|
||||||
|
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
|
||||||
|
<input
|
||||||
|
id="confirm_password"
|
||||||
|
v-model="formData.confirm_password"
|
||||||
|
:type="showConfirm ? 'text' : 'password'"
|
||||||
|
:class="{ 'is-invalid': formData.confirm_password && formData.confirm_password !== formData.new_password }"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="new-password"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-password-toggle"
|
||||||
|
:aria-label="showConfirm ? 'Nascondi conferma password' : 'Mostra conferma password'"
|
||||||
|
@click="showConfirm = !showConfirm"
|
||||||
|
>
|
||||||
|
<BIIcon :name="showConfirm ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger mb-0" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-100 py-2"
|
||||||
|
:disabled="!canSubmit || isLoading"
|
||||||
|
>
|
||||||
|
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
{{ isLoading ? 'Aggiornamento in corso' : 'Aggiorna password' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.password-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-field-control {
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #c5d0db;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
padding-left: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-field-control:focus-within {
|
||||||
|
border-color: #06c;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-field-icon {
|
||||||
|
color: #5c6f82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-field-control .form-control {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-password-toggle {
|
||||||
|
align-items: center;
|
||||||
|
color: #06c;
|
||||||
|
display: inline-flex;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 0 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-password-toggle:hover,
|
||||||
|
.btn-password-toggle:focus {
|
||||||
|
background: rgba(0, 102, 204, 0.08);
|
||||||
|
color: #06c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'guest'
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Accedi - Visione' })
|
||||||
|
|
||||||
|
const { requestOtp, verifyOtp, loginWithPassword, handleLoginSuccess } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
const loginMethod = ref<'otp' | 'password'>('otp')
|
||||||
|
const otpStep = ref<1 | 2>(1)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const otpCode = ref('')
|
||||||
|
const showPassword = ref(false)
|
||||||
|
|
||||||
|
const handleRequestOtp = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
await requestOtp({ email: email.value })
|
||||||
|
otpStep.value = 2
|
||||||
|
} catch (err: any) {
|
||||||
|
errorMessage.value = err.data?.message || 'Errore durante la richiesta OTP. Riprova.'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVerifyOtp = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const res = await verifyOtp({ email: email.value, code: otpCode.value })
|
||||||
|
await handleLoginSuccess(res.token)
|
||||||
|
} catch (err: any) {
|
||||||
|
errorMessage.value = 'Codice OTP non valido o scaduto.'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePasswordLogin = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const res = await loginWithPassword({ email: email.value, password: password.value })
|
||||||
|
await handleLoginSuccess(res.token)
|
||||||
|
} catch (err: any) {
|
||||||
|
errorMessage.value = 'Credenziali non valide.'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-flex align-items-center bg-light py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-md-8 col-lg-5">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<BIIcon name="it-user" size="xl" color="primary" class="mb-2" />
|
||||||
|
<h1 class="h3 font-weight-bold">Accedi a Visione</h1>
|
||||||
|
<p class="text-muted">Il portale delle segnalazioni urbane</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bg shadow-sm">
|
||||||
|
<div class="card-body p-4 p-md-5">
|
||||||
|
|
||||||
|
|
||||||
|
<button class="btn btn-primary w-100 mb-4 d-flex align-items-center justify-content-center gap-2" disabled title="Integrazione CIE prevista per la fase successiva">
|
||||||
|
<BIIcon name="it-card" size="sm" color="white" />
|
||||||
|
Entra con CIE (Presto disponibile)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center my-4">
|
||||||
|
<hr class="flex-grow-1">
|
||||||
|
<span class="mx-3 text-muted small text-uppercase">Oppure</span>
|
||||||
|
<hr class="flex-grow-1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger mb-4" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="loginMethod === 'otp'">
|
||||||
|
<form v-if="otpStep === 1" @submit.prevent="handleRequestOtp">
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<input type="email" class="form-control" id="email" v-model="email" required>
|
||||||
|
<label for="email" :class="{ 'active': email.length > 0 }">Indirizzo Email</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100" :disabled="isLoading">
|
||||||
|
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
|
Ricevi codice OTP
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form v-else @submit.prevent="handleVerifyOtp">
|
||||||
|
<div class="alert alert-success mb-4 small">
|
||||||
|
Abbiamo inviato un codice OTP a <strong>{{ email }}</strong>.
|
||||||
|
</div>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<input type="text" class="form-control text-center text-tracking-widest fs-4" id="otp" v-model="otpCode" required maxlength="6" autocomplete="one-time-code">
|
||||||
|
<label for="otp" :class="{ 'active': otpCode.length > 0 }">Codice a 6 cifre</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100 mb-3" :disabled="isLoading">
|
||||||
|
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
|
Verifica e Accedi
|
||||||
|
</button>
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="#" class="small text-decoration-none" @click.prevent="otpStep = 1">Modifica indirizzo email</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<form v-else @submit.prevent="handlePasswordLogin">
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<input type="email" class="form-control" id="emailPwd" v-model="email" required>
|
||||||
|
<label for="emailPwd" :class="{ 'active': email.length > 0 }">Indirizzo Email</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="form-group mb-4 position-relative">
|
||||||
|
<input :type="showPassword ? 'text' : 'password'" class="form-control pe-5" id="password" v-model="password" required>
|
||||||
|
<label for="password" :class="{ 'active': password.length > 0 }">Password</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn bg-transparent border-0 shadow-none position-absolute p-2"
|
||||||
|
style="right: 0; top: 2px; z-index: 10;"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
:aria-pressed="showPassword"
|
||||||
|
aria-label="Mostra/Nascondi password"
|
||||||
|
>
|
||||||
|
<BIIcon :name="showPassword ? 'it-password-visible' : 'it-password-invisible'" color="primary" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100 mb-3" :disabled="isLoading">
|
||||||
|
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
|
Accedi
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="#" class="small text-decoration-none" @click.prevent="loginMethod = loginMethod === 'otp' ? 'password' : 'otp'; errorMessage = ''">
|
||||||
|
{{ loginMethod === 'otp' ? 'Vuoi usare la password? Clicca qui' : 'Usa il login sicuro senza password (OTP)' }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<p class="small text-muted">Non hai un account? <NuxtLink to="/auth/register" class="font-weight-bold">Registrati ora</NuxtLink></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-tracking-widest {
|
||||||
|
letter-spacing: 0.5em;
|
||||||
|
}
|
||||||
|
.pe-5 {
|
||||||
|
padding-right: 3rem !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import CodiceFiscale from 'codice-fiscale-js'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'guest'
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Registrazione - Visione' })
|
||||||
|
|
||||||
|
const { register } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const successMessage = ref(false)
|
||||||
|
const showPassword = ref(false)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
fiscal_code: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
phone: '',
|
||||||
|
privacy: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeFiscalCode = (fiscalCode = '') => fiscalCode.trim().toUpperCase()
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!form.value.privacy) {
|
||||||
|
errorMessage.value = "È necessario accettare l'informativa sulla privacy."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fiscalCode = normalizeFiscalCode(form.value.fiscal_code)
|
||||||
|
const expected = CodiceFiscale.surnameCode(form.value.last_name) + CodiceFiscale.nameCode(form.value.first_name)
|
||||||
|
|
||||||
|
if (!CodiceFiscale.check(fiscalCode)) {
|
||||||
|
errorMessage.value = 'Il Codice Fiscale inserito non è valido.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fiscalCode.slice(0, 6) !== expected) {
|
||||||
|
errorMessage.value = 'Il Codice Fiscale non corrisponde a nome e cognome inseriti.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
first_name: form.value.first_name,
|
||||||
|
last_name: form.value.last_name,
|
||||||
|
fiscal_code: fiscalCode,
|
||||||
|
email: form.value.email,
|
||||||
|
password: form.value.password,
|
||||||
|
phone: form.value.phone || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
successMessage.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/auth/login')
|
||||||
|
}, 3000)
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
errorMessage.value = err.data?.message || 'Errore durante la registrazione. Controlla i dati inseriti.'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-flex align-items-center bg-light py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<BIIcon name="it-user" size="xl" color="primary" class="mb-2" />
|
||||||
|
<h1 class="h3 font-weight-bold">Registrazione Cittadino</h1>
|
||||||
|
<p class="text-muted">Crea il tuo profilo per iniziare a segnalare</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bg shadow-sm">
|
||||||
|
<div class="card-body p-4 p-md-5">
|
||||||
|
|
||||||
|
<div v-if="successMessage" class="alert alert-success" role="alert">
|
||||||
|
Registrazione completata con successo! Reindirizzamento al login...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger mb-4" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-if="!successMessage" @submit.prevent="handleRegister">
|
||||||
|
|
||||||
|
<h2 class="h5 text-primary font-weight-bold mb-4 border-bottom pb-2">Dati Anagrafici</h2>
|
||||||
|
|
||||||
|
<!-- FIX LAYOUT: form-group separato da col -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12 col-md-6 mb-4 mb-md-0">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" class="form-control" id="nome" v-model="form.first_name" required>
|
||||||
|
<label for="nome" :class="{ 'active': form.first_name.length > 0 }">Nome</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" class="form-control" id="cognome" v-model="form.last_name" required>
|
||||||
|
<label for="cognome" :class="{ 'active': form.last_name.length > 0 }">Cognome</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<input type="text" class="form-control text-uppercase" id="cf" v-model="form.fiscal_code" required maxlength="16">
|
||||||
|
<label for="cf" :class="{ 'active': form.fiscal_code.length > 0 }">Codice Fiscale</label>
|
||||||
|
<small class="form-text text-muted">Sarà utilizzato come identificativo univoco nel sistema PA.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="h5 text-primary font-weight-bold mt-5 mb-4 border-bottom pb-2">Credenziali e Contatti</h2>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12 col-md-6 mb-4 mb-md-0">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="email" class="form-control" id="regEmail" v-model="form.email" required>
|
||||||
|
<label for="regEmail" :class="{ 'active': form.email.length > 0 }">Indirizzo Email</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<!-- INPUT PASSWORD FIXATO -->
|
||||||
|
<div class="form-group position-relative">
|
||||||
|
<input :type="showPassword ? 'text' : 'password'" class="form-control pe-5" id="regPwd" v-model="form.password" required minlength="10">
|
||||||
|
<label for="regPwd" :class="{ 'active': form.password.length > 0 }">Password (min. 10 crt)</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn bg-transparent border-0 shadow-none position-absolute p-2"
|
||||||
|
style="right: 0; top: 2px; z-index: 10;"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
:aria-pressed="showPassword"
|
||||||
|
aria-label="Mostra/Nascondi password"
|
||||||
|
>
|
||||||
|
<BIIcon :name="showPassword ? 'it-password-visible' : 'it-password-invisible'" color="primary" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-5">
|
||||||
|
<input type="tel" class="form-control" id="phone" v-model="form.phone">
|
||||||
|
<label for="phone" :class="{ 'active': form.phone?.length > 0 }">Numero di telefono (Opzionale)</label>
|
||||||
|
<small class="form-text text-muted">Faciliterà il contatto da parte degli operatori comunali in caso di intervento.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Privacy Checkbox -->
|
||||||
|
<div class="form-check mb-4">
|
||||||
|
<input class="form-check-input" type="checkbox" id="privacyCheck" v-model="form.privacy" required>
|
||||||
|
<label class="form-check-label small" for="privacyCheck">
|
||||||
|
Dichiaro di aver letto l'<NuxtLink to="/privacy" target="_blank">Informativa sulla Privacy</NuxtLink> e accetto il trattamento dei dati personali.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100 btn-lg" :disabled="isLoading">
|
||||||
|
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||||
|
Registra Profilo
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<p class="small text-muted">Hai già un account? <NuxtLink to="/auth/login" class="font-weight-bold">Torna al Login</NuxtLink></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Previene che il testo finisca sotto l'icona */
|
||||||
|
.pe-5 {
|
||||||
|
padding-right: 3rem !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
import CitizenDashboard from '~/components/dashboard/CitizenDashboard.vue'
|
||||||
|
import OperatorDashboard from '~/components/dashboard/OperatorDashboard.vue'
|
||||||
|
import AdminDashboard from '~/components/dashboard/AdminDashboard.vue'
|
||||||
|
|
||||||
|
const currentDashboard = computed(() => {
|
||||||
|
const role = user.value?.role?.toUpperCase()
|
||||||
|
if (role === 'CITIZEN') return CitizenDashboard
|
||||||
|
if (role === 'OPERATOR') return OperatorDashboard
|
||||||
|
if (role === 'ADMIN') return AdminDashboard
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const heroTitle = computed(() => {
|
||||||
|
const role = user.value?.role?.toUpperCase()
|
||||||
|
if (role === 'ADMIN') return 'Pannello Admin'
|
||||||
|
if (role === 'OPERATOR') return 'Gestione Operativa'
|
||||||
|
return 'Dashboard'
|
||||||
|
})
|
||||||
|
|
||||||
|
const heroSubtitle = computed(() => {
|
||||||
|
const role = user.value?.role?.toUpperCase()
|
||||||
|
if (role === 'ADMIN') return 'Accesso amministrativo per configurazioni, utenti e moderazione'
|
||||||
|
if (role === 'OPERATOR') return 'Prendi in carico e gestisci le segnalazioni in corso'
|
||||||
|
return 'Riepilogo rapido delle tue segnalazioni e dei punti cittadino'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="it-hero-wrapper it-primary hero-compact">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 text-center">
|
||||||
|
<div class="it-hero-text-wrapper p-4">
|
||||||
|
<h1 class="text-white font-weight-bold">{{ heroTitle }}</h1>
|
||||||
|
<p class="text-white mt-2 lead">
|
||||||
|
{{ heroSubtitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container my-5">
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<h4 class="mb-2 text-muted">
|
||||||
|
Benvenuto, <strong>{{ user?.first_name }} {{ user?.last_name }}</strong>.
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<component :is="currentDashboard" v-if="currentDashboard" />
|
||||||
|
|
||||||
|
<div v-else class="alert alert-warning" role="alert">
|
||||||
|
Caricamento pannello di controllo in corso...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+4
-4
@@ -19,10 +19,10 @@ const { data: openDataResponse } = await useAsyncData('recent-fixes', () =>
|
|||||||
<div class="it-hero-wrapper bg-light">
|
<div class="it-hero-wrapper bg-light">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 col-lg-8 offset-lg-2 text-center">
|
<div class="col-12 col-lg-8 mx-auto text-center">
|
||||||
<div class="it-hero-text-wrapper p-4">
|
<div class="it-hero-text-wrapper p-4">
|
||||||
<h1 class="no_toc font-weight-bold text-dark">Miglioriamo insieme la città</h1>
|
<h1 class="no_toc font-weight-bold">Miglioriamo insieme la città</h1>
|
||||||
<p class="d-none d-lg-block mt-3 mb-4 text-secondary">
|
<p class="d-none d-lg-block mt-3 mb-4 text-muted">
|
||||||
Il portale ufficiale nazionale per segnalare disservizi urbani. Scatta una foto, invia la segnalazione
|
Il portale ufficiale nazionale per segnalare disservizi urbani. Scatta una foto, invia la segnalazione
|
||||||
e monitora l'intervento in totale trasparenza.
|
e monitora l'intervento in totale trasparenza.
|
||||||
</p>
|
</p>
|
||||||
@@ -68,7 +68,7 @@ const { data: openDataResponse } = await useAsyncData('recent-fixes', () =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-light py-5 border-top border-bottom">
|
<div class="bg-light py-5">
|
||||||
<div class="container my-4">
|
<div class="container my-4">
|
||||||
<h2 class="text-center mb-5 font-weight-bold">I numeri del nostro Comune</h2>
|
<h2 class="text-center mb-5 font-weight-bold">I numeri del nostro Comune</h2>
|
||||||
<div class="row text-center">
|
<div class="row text-center">
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// La pagina è statica, usiamo solo i componenti UI
|
||||||
|
useHead({
|
||||||
|
title: 'Informazioni sul Progetto - Visione',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- HERO SECTION (Senza pt-5/mt-5 come richiesto) -->
|
||||||
|
<div class="it-hero-wrapper it-primary">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 text-center">
|
||||||
|
<div class="it-hero-text-wrapper p-4">
|
||||||
|
<h1 class="text-white font-weight-bold">Il Progetto Visione</h1>
|
||||||
|
<p class="text-white mt-3 lead font-weight-semibold">
|
||||||
|
La piattaforma digitale per la gestione intelligente delle segnalazioni urbane,
|
||||||
|
pensata per avvicinare cittadini e pubblica amministrazione.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OBIETTIVO DEL PROGETTO -->
|
||||||
|
<div class="container my-5 py-3">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-8 offset-lg-2">
|
||||||
|
<h2 class="mb-4 font-weight-bold">Migliorare la qualità della vita in città</h2>
|
||||||
|
<p class="lead text-muted">
|
||||||
|
Visione nasce dall'esigenza di fornire alle amministrazioni comunali uno strumento moderno per mappare, gestire e risolvere disservizi presenti sul territorio (buche stradali, illuminazione guasta, rifiuti abbandonati).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Il sistema non si limita a raccogliere dati, ma automatizza l'assegnazione alle squadre operative competenti tramite logiche geospaziali, traccia l'intero ciclo di vita della segnalazione e restituisce trasparenza ai cittadini tramite un portale Open Data dedicato.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RUOLI E FUNZIONALITÀ (Card Grid) -->
|
||||||
|
<div class="bg-light py-5">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="text-center mb-5 font-weight-bold">Come funziona il sistema</h2>
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<!-- Cittadino -->
|
||||||
|
<div class="col-12 col-md-4 mb-4">
|
||||||
|
<div class="card-wrapper card-space h-100">
|
||||||
|
<div class="card card-bg h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<BIIcon name="it-user" size="xl" color="primary" class="mb-3" />
|
||||||
|
<h3 class="card-title h5 font-weight-bold">Cittadini</h3>
|
||||||
|
<p class="card-text text-muted">
|
||||||
|
Tramite autenticazione sicura (OTP via email o CIE), i cittadini possono inviare segnalazioni geolocalizzate complete di foto.
|
||||||
|
<br><br>
|
||||||
|
Per incentivare la partecipazione, è integrato un sistema di <strong>gamification</strong>: ogni segnalazione valida e risolta fa guadagnare Punti Cittadino, premiando la cittadinanza attiva. Possono inoltre chattare con gli operatori e lasciare un feedback finale.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Operatore -->
|
||||||
|
<div class="col-12 col-md-4 mb-4">
|
||||||
|
<div class="card-wrapper card-space h-100">
|
||||||
|
<div class="card card-bg h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<BIIcon name="it-tool" size="xl" color="primary" class="mb-3" />
|
||||||
|
<h3 class="card-title h5 font-weight-bold">Operatori e Squadre</h3>
|
||||||
|
<p class="card-text text-muted">
|
||||||
|
Il personale comunale visualizza una dashboard con le segnalazioni in ingresso. Possono prenderle in carico, comunicare con l'utente e assegnarle alle squadre di intervento sul territorio.
|
||||||
|
<br><br>
|
||||||
|
Il sistema gestisce l'intero flusso di stato: da <em>Aperta</em> a <em>In Revisione</em>, fino a <em>In Lavorazione</em> e <em>Risolta</em>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Amministratore -->
|
||||||
|
<div class="col-12 col-md-4 mb-4">
|
||||||
|
<div class="card-wrapper card-space h-100">
|
||||||
|
<div class="card card-bg h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<BIIcon name="it-settings" size="xl" color="primary" class="mb-3" />
|
||||||
|
<h3 class="card-title h5 font-weight-bold">Amministrazione</h3>
|
||||||
|
<p class="card-text text-muted">
|
||||||
|
Gli amministratori hanno il controllo completo della piattaforma. Possono definire le aree urbane tramite poligoni GeoJSON, gestire i permessi degli utenti, supervisionare i feedback e moderare eventuali abusi.
|
||||||
|
<br><br>
|
||||||
|
Hanno inoltre accesso a statistiche avanzate per individuare le criticità sul territorio e monitorare i tempi medi di risoluzione.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STACK TECNOLOGICO E FILOSOFIA -->
|
||||||
|
<div class="container my-5 py-4">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-12 col-lg-6 mb-4 mb-lg-0">
|
||||||
|
<h2 class="font-weight-bold mb-4">Tecnologia e Indipendenza</h2>
|
||||||
|
<ul class="it-list">
|
||||||
|
<li>
|
||||||
|
<BIIcon name="it-code-circle" color="primary" class="me-2" />
|
||||||
|
<strong>Architettura:</strong> Frontend reattivo e multipiattaforma sviluppato in <em>Vue.js/Nuxt 3</em>. Backend ad altissime prestazioni scritto in <em>C++</em> utilizzando il framework <em>Drogon</em>.
|
||||||
|
</li>
|
||||||
|
<li class="mt-3">
|
||||||
|
<BIIcon name="it-lock" color="primary" class="me-2" />
|
||||||
|
<strong>Sicurezza:</strong> Login Passwordless (OTP nativo) e sistema di autorizzazione Role-Based basato su token JWT.
|
||||||
|
</li>
|
||||||
|
<li class="mt-3">
|
||||||
|
<BIIcon name="it-piu" color="primary" class="me-2" />
|
||||||
|
<strong>Open Data:</strong> Piena aderenza alle linee guida AGID sulla trasparenza, con esportazione di dati anonimizzati accessibili al pubblico.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-5 offset-lg-1">
|
||||||
|
<!-- Callout Ribellione Gitea -->
|
||||||
|
<div class="callout callout-highlight callout-warning">
|
||||||
|
<div class="callout-title">
|
||||||
|
<BIIcon name="it-warning-circle" />
|
||||||
|
Un approccio etico al codice sorgente
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
In risposta alle policy sempre più aggressive delle grandi piattaforme commerciali riguardo l'addestramento non consensuale di modelli di Intelligenza Artificiale sui repository privati, lo sviluppo di questo progetto è ospitato su un'istanza <strong>self-hosted di Gitea</strong>.
|
||||||
|
<br><br>
|
||||||
|
Una scelta di principio per garantire il controllo totale, la privacy e la vera sovranità sui dati e sul codice.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CREDITI ACCADEMICI (Footer Style Callout) -->
|
||||||
|
<div class="container mb-5 pb-5">
|
||||||
|
<div class="card-wrapper card-space">
|
||||||
|
<div class="card card-bg border-start border-primary border-5">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-12 col-md-8">
|
||||||
|
<h4 class="h5 font-weight-bold text-primary mb-2">Crediti Accademici</h4>
|
||||||
|
<p class="mb-1">
|
||||||
|
Questo progetto è stato realizzato nell'ambito del corso di <strong>Laboratorio di Programmazione</strong> (tenuto dal Prof. Vessio).
|
||||||
|
</p>
|
||||||
|
<p class="text-muted small mb-0">
|
||||||
|
Università degli Studi di Bari "Aldo Moro" - Dipartimento di Informatica.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 mt-3 mt-md-0 text-md-end">
|
||||||
|
<p class="font-weight-bold mb-1">Sviluppato da:</p>
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li>Renato Loiacono</li>
|
||||||
|
<li>Michelangelo Parisi</li>
|
||||||
|
<li>Gabriele Schiraldi</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Leggere correzioni stilistiche per le liste */
|
||||||
|
.it-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
title: 'Note Legali - Visione',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- HERO SECTION -->
|
||||||
|
<div class="it-hero-wrapper it-primary">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 text-center">
|
||||||
|
<div class="it-hero-text-wrapper p-4">
|
||||||
|
<h1 class="text-white font-weight-bold">Note Legali</h1>
|
||||||
|
<p class="text-white mt-3 lead font-weight-semibold">
|
||||||
|
Termini e condizioni di utilizzo della piattaforma Visione.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO PRINCIPALE -->
|
||||||
|
<div class="container my-5 py-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-8 offset-lg-2">
|
||||||
|
|
||||||
|
<h2 class="h3 font-weight-bold mb-3">1. Condizioni generali d'uso</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Il presente documento definisce le condizioni generali di utilizzo del portale web "Visione" (di seguito, "Piattaforma"). L'accesso, la navigazione e l'utilizzo della Piattaforma comportano l'accettazione esplicita delle presenti note legali. Gli utenti sono invitati a leggere attentamente questo documento prima di utilizzare i servizi offerti.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h3 font-weight-bold mb-3">2. Finalità della piattaforma</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Visione è uno strumento digitale concepito per facilitare la comunicazione tra i cittadini e la Pubblica Amministrazione. Consente agli utenti registrati di inviare segnalazioni relative a disservizi urbani (es. buche, guasti all'illuminazione, rifiuti), allegando materiale fotografico e dati di geolocalizzazione, al fine di snellire i processi di intervento delle squadre comunali.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h3 font-weight-bold mb-3">3. Proprietà intellettuale e Codice Sorgente</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
In un'ottica di totale trasparenza e indipendenza etica, il codice sorgente del progetto "Visione" è sviluppato e mantenuto su un'istanza self-hosted di <strong>Gitea</strong>. Tutti i diritti sui loghi, testi, e architettura del software, ove non diversamente specificato da licenze Open Source, appartengono ai rispettivi autori.
|
||||||
|
È severamente vietato l'utilizzo di script automatizzati per l'estrazione massiva di dati dalla Piattaforma, ad eccezione degli endpoint ufficiali forniti nella sezione Open Data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h3 font-weight-bold mb-3">4. Limitazione di responsabilità</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
L'Amministrazione e gli sviluppatori si impegnano a garantire l'aggiornamento e l'accuratezza delle informazioni presenti sulla Piattaforma. Tuttavia, non si assumono alcuna responsabilità per eventuali ritardi, disservizi o per la mancata e/o ritardata risoluzione delle problematiche segnalate dagli utenti. Le segnalazioni inserite non hanno valore di denuncia formale alle Autorità di Pubblica Sicurezza. In caso di emergenze, i cittadini sono tenuti a contattare direttamente il Numero Unico Europeo per le Emergenze (112).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h3 font-weight-bold mb-3">5. Moderazione e Abusi</h2>
|
||||||
|
<p class="text-muted mb-5">
|
||||||
|
Gli operatori e gli amministratori di sistema si riservano il diritto di moderare, nascondere o rimuovere segnalazioni contenenti materiale inappropriato, offensivo, non pertinente o illegale. L'abuso ripetuto del sistema comporterà il ban permanente dell'account dell'utente, associato al proprio Codice Fiscale.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- DISCLAIMER ACCADEMICO -->
|
||||||
|
<div class="callout callout-highlight callout-info mt-5">
|
||||||
|
<div class="callout-title">
|
||||||
|
<BIIcon name="it-info-circle" />
|
||||||
|
Disclaimer di Progetto Accademico
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
"Visione" è un progetto software realizzato esclusivamente per finalità didattiche all'interno del corso di <em>Laboratorio di Programmazione</em> (Prof. Vessio) presso l'<strong>Università degli Studi di Bari "Aldo Moro"</strong>.
|
||||||
|
<br>Autori: Renato Loiacono, Michelangelo Parisi e Gabriele Schiraldi. <br>I dati elaborati in questa istanza hanno scopo puramente dimostrativo e non costituiscono un reale servizio comunale attivo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Notifiche - Visione' })
|
||||||
|
|
||||||
|
const { fetchNotifications, markNotificationRead } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(15)
|
||||||
|
|
||||||
|
const { data: notificationsData, refresh: refreshNotifications } = await useAsyncData(
|
||||||
|
'notifications',
|
||||||
|
() => fetchNotifications({ page: page.value, per_page: perPage.value })
|
||||||
|
)
|
||||||
|
|
||||||
|
const notifications = computed(() => notificationsData.value?.notifications || [])
|
||||||
|
const totalNotifications = computed(() => notificationsData.value?.notifications?.length || 0)
|
||||||
|
const unreadCount = computed(() => notifications.value.filter((n: any) => !n.read_at).length)
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
page.value = newPage
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkAsRead = async (notificationId: number) => {
|
||||||
|
try {
|
||||||
|
await markNotificationRead(notificationId)
|
||||||
|
await refreshNotifications()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking notification as read:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNotificationClick = async (notification: any) => {
|
||||||
|
if (!notification.read_at) {
|
||||||
|
await handleMarkAsRead(notification.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.report_id) {
|
||||||
|
router.push(`/reports/${notification.report_id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Proprio ora'
|
||||||
|
if (diffMins < 60) return `${diffMins}m fa`
|
||||||
|
if (diffHours < 24) return `${diffHours}h fa`
|
||||||
|
if (diffDays < 7) return `${diffDays}d fa`
|
||||||
|
|
||||||
|
return date.toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNotificationIcon = (type: string): string => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
'report_created': 'it-pencil',
|
||||||
|
'report_assigned': 'it-check-circle',
|
||||||
|
'report_updated': 'it-refresh',
|
||||||
|
'report_resolved': 'it-check-circle',
|
||||||
|
'message_received': 'it-comment',
|
||||||
|
'feedback_requested': 'it-star',
|
||||||
|
'reward_earned': 'it-star',
|
||||||
|
}
|
||||||
|
return icons[type] || 'it-bell'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNotificationColor = (type: string): string => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
'report_created': 'primary',
|
||||||
|
'report_assigned': 'info',
|
||||||
|
'report_updated': 'info',
|
||||||
|
'report_resolved': 'success',
|
||||||
|
'message_received': 'primary',
|
||||||
|
'feedback_requested': 'warning',
|
||||||
|
'reward_earned': 'warning',
|
||||||
|
}
|
||||||
|
return colors[type] || 'secondary'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- HERO -->
|
||||||
|
<div class="bg-light border-bottom py-4 mb-4">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="h3 font-weight-bold mb-2">
|
||||||
|
<BIIcon name="it-bell" size="sm" color="primary" class="me-2" />
|
||||||
|
Centro Notifiche
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
{{ unreadCount > 0 ? `Hai ${unreadCount} notifiche non lette` : 'Tutte le notifiche lette' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO -->
|
||||||
|
<div class="container mb-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-10 offset-lg-1">
|
||||||
|
|
||||||
|
<!-- FILTRI (Opzionale) -->
|
||||||
|
<div class="d-flex gap-2 mb-4">
|
||||||
|
<button class="btn btn-sm btn-outline-primary active">Tutti</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary">Solo non letti</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LISTA NOTIFICHE -->
|
||||||
|
<div v-if="notifications.length === 0" class="text-center py-5">
|
||||||
|
<BIIcon name="it-bell" size="xl" color="info" class="mb-3 d-block" />
|
||||||
|
<h6 class="text-muted">Nessuna notifica</h6>
|
||||||
|
<p class="small text-muted">Quando riceverai nuovi aggiornamenti sulle tue segnalazioni, appariranno qui.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="list-group">
|
||||||
|
<button
|
||||||
|
v-for="notification in notifications"
|
||||||
|
:key="notification.id"
|
||||||
|
type="button"
|
||||||
|
class="list-group-item list-group-item-action border-0 p-3 mb-3 rounded-2"
|
||||||
|
:class="{
|
||||||
|
'bg-light': !notification.read_at,
|
||||||
|
'bg-white': notification.read_at,
|
||||||
|
}"
|
||||||
|
@click="handleNotificationClick(notification)"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-items-start gap-3">
|
||||||
|
<!-- ICONA -->
|
||||||
|
<div class="flex-shrink-0 mt-1">
|
||||||
|
<div class="rounded-circle p-2" :class="`bg-${getNotificationColor(notification.type)}`" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<BIIcon :name="getNotificationIcon(notification.type)" size="sm" color="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO -->
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex align-items-start justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1 font-weight-bold" :class="{ 'text-dark': !notification.read_at }">
|
||||||
|
{{ notification.title }}
|
||||||
|
</h6>
|
||||||
|
<p class="mb-1 small" :class="{ 'text-muted': notification.read_at }">
|
||||||
|
{{ notification.body }}
|
||||||
|
</p>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ formatDate(notification.created_at) }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- INDICATORE NON LETTO -->
|
||||||
|
<div v-if="!notification.read_at" class="flex-shrink-0">
|
||||||
|
<span class="badge bg-primary rounded-pill">Nuovo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AZIONI RAPIDE -->
|
||||||
|
<div v-if="notification.report_id" class="mt-2 d-flex gap-2">
|
||||||
|
<NuxtLink :to="`/reports/${notification.report_id}`" class="btn btn-sm btn-outline-primary">
|
||||||
|
<BIIcon name="it-arrow-right" size="xs" class="me-1" />
|
||||||
|
Vedi segnalazione
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
v-if="!notification.read_at"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
@click.stop="handleMarkAsRead(notification.id)"
|
||||||
|
>
|
||||||
|
Segna come letta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PAGINATION -->
|
||||||
|
<Pagination
|
||||||
|
v-if="notifications.length > 0"
|
||||||
|
:current-page="page"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-items="totalNotifications"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-group-item {
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bg-light {
|
||||||
|
border-left: 4px solid #006f9d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-2 {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import type { Map as LeafletMap, CircleMarker, LatLngBoundsExpression } from 'leaflet'
|
||||||
|
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
|
||||||
|
|
||||||
|
// 1. API Fetching Logic
|
||||||
|
const { getOpenDataReports } = usePublic()
|
||||||
|
|
||||||
|
// Reactive pagination state
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const perPage = ref(9)
|
||||||
|
|
||||||
|
// Fetch data automatically when currentPage changes
|
||||||
|
const { data: openDataResponse, pending } = await useAsyncData(
|
||||||
|
'open-data-reports',
|
||||||
|
() => getOpenDataReports({ page: currentPage.value, per_page: perPage.value }),
|
||||||
|
{ watch: [currentPage] } // Nuxt will re-fetch when currentPage changes
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleReports = computed(() =>
|
||||||
|
(openDataResponse.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pagination logic: If we receive less items than `perPage`, we are on the last page.
|
||||||
|
const isLastPage = computed(() => {
|
||||||
|
if (!openDataResponse.value?.reports) return true
|
||||||
|
return openDataResponse.value.reports.length < perPage.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextPage = () => {
|
||||||
|
if (!isLastPage.value) currentPage.value++
|
||||||
|
}
|
||||||
|
const prevPage = () => {
|
||||||
|
if (currentPage.value > 1) currentPage.value--
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. View Toggle (Grid vs Map)
|
||||||
|
const currentView = ref<'grid' | 'map'>('grid')
|
||||||
|
const mapContainer = ref<HTMLElement | null>(null)
|
||||||
|
let leaflet: typeof import('leaflet') | null = null
|
||||||
|
let map: LeafletMap | null = null
|
||||||
|
let markers: CircleMarker[] = []
|
||||||
|
|
||||||
|
// 3. Helper to style status badges
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'RESOLVED': return { class: 'bg-success', text: 'Risolto', icon: 'it-check-circle' }
|
||||||
|
case 'CLOSED': return { class: 'bg-secondary', text: 'Chiuso', icon: 'it-lock' }
|
||||||
|
default: return { class: 'bg-info', text: status, icon: 'it-info-circle' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLeaflet = async () => {
|
||||||
|
if (leaflet) return leaflet
|
||||||
|
leaflet = await import('leaflet')
|
||||||
|
return leaflet
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearMarkers = () => {
|
||||||
|
if (!map) return
|
||||||
|
markers.forEach((marker) => marker.removeFrom(map!))
|
||||||
|
markers = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderMapMarkers = async () => {
|
||||||
|
if (!import.meta.client || !mapContainer.value) return
|
||||||
|
|
||||||
|
const L = await loadLeaflet()
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
clearMarkers()
|
||||||
|
|
||||||
|
if (visibleReports.value.length === 0) {
|
||||||
|
map.setView(BARI_CENTER, 12)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleReports.value.forEach((report) => {
|
||||||
|
const marker = L.circleMarker([report.latitude, report.longitude], {
|
||||||
|
radius: 8,
|
||||||
|
weight: 2,
|
||||||
|
color: '#ffffff',
|
||||||
|
fillColor: getStatusBadge(report.status).class === 'bg-success' ? '#008758' : getStatusBadge(report.status).class === 'bg-secondary' ? '#5c6f82' : '#2f80ed',
|
||||||
|
fillOpacity: 0.95,
|
||||||
|
})
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div class="open-data-popup">
|
||||||
|
<strong>${report.category_name}</strong><br/>
|
||||||
|
<span>ID #${report.id}</span><br/>
|
||||||
|
<span>${report.area_name || 'Area comunale non specificata'}</span><br/>
|
||||||
|
<span>Segnalato ${new Date(report.created_at).toLocaleDateString('it-IT')}</span>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
|
||||||
|
marker.addTo(map!)
|
||||||
|
markers.push(marker)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fitBounds: LatLngBoundsExpression = visibleReports.value.map((report) => [report.latitude, report.longitude])
|
||||||
|
map.fitBounds(fitBounds, { padding: [32, 32], maxZoom: 15 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const initMap = async () => {
|
||||||
|
if (!import.meta.client || !mapContainer.value || map) return
|
||||||
|
|
||||||
|
const L = await loadLeaflet()
|
||||||
|
const bariBounds = L.latLngBounds(
|
||||||
|
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
||||||
|
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
||||||
|
)
|
||||||
|
|
||||||
|
map = L.map(mapContainer.value, {
|
||||||
|
maxBounds: bariBounds,
|
||||||
|
maxBoundsViscosity: 1,
|
||||||
|
minZoom: 11,
|
||||||
|
}).setView(BARI_CENTER, 12)
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
await renderMapMarkers()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(currentView, async (view) => {
|
||||||
|
if (view !== 'map') return
|
||||||
|
await nextTick()
|
||||||
|
await initMap()
|
||||||
|
map?.invalidateSize()
|
||||||
|
await renderMapMarkers()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(visibleReports, async () => {
|
||||||
|
if (currentView.value === 'map' && map) {
|
||||||
|
await renderMapMarkers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearMarkers()
|
||||||
|
map?.remove()
|
||||||
|
map = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- HERO SECTION (Small version for internal pages) -->
|
||||||
|
<div class="it-hero-wrapper it-primary">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 text-center">
|
||||||
|
<div class="it-hero-text-wrapper p-4">
|
||||||
|
<h1 class="text-white font-weight-bold">Open Data Interventi</h1>
|
||||||
|
<p class="text-white mt-2 lead">
|
||||||
|
Esplora i dati pubblici degli interventi urbani risolti. La trasparenza al servizio del cittadino.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MAIN CONTENT -->
|
||||||
|
<div class="container my-5">
|
||||||
|
|
||||||
|
<!-- TOOLBAR: View Toggles & Export -->
|
||||||
|
<div class="row align-items-center mb-5 pb-3 border-bottom">
|
||||||
|
<div class="col-12 col-md-6 mb-3 mb-md-0 d-flex gap-2">
|
||||||
|
<!-- View Toggle Buttons -->
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="currentView === 'grid' ? 'btn-primary' : 'btn-outline-primary'"
|
||||||
|
@click="currentView = 'grid'"
|
||||||
|
>
|
||||||
|
<BIIcon name="it-grid" size="sm" class="me-1" :color="currentView === 'grid' ? 'white' : 'primary'" />
|
||||||
|
Griglia Dati
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="currentView === 'map' ? 'btn-primary' : 'btn-outline-primary'"
|
||||||
|
@click="currentView = 'map'"
|
||||||
|
>
|
||||||
|
<BIIcon name="it-map-marker" size="sm" class="me-1" :color="currentView === 'map' ? 'white' : 'primary'" />
|
||||||
|
Mappa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 text-md-end">
|
||||||
|
<!-- Fake Export CTA (Very common in Open Data portals) -->
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" title="Scarica dataset in formato JSON">
|
||||||
|
<BIIcon name="it-download" size="sm" class="me-1" />
|
||||||
|
Scarica JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LOADER -->
|
||||||
|
<div v-if="pending" class="d-flex justify-content-center my-5 py-5">
|
||||||
|
<div class="progress-spinner progress-spinner-active" role="status">
|
||||||
|
<span class="visually-hidden">Caricamento in corso...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GRID VIEW -->
|
||||||
|
<div v-else-if="currentView === 'grid'">
|
||||||
|
<div class="row" v-if="openDataResponse?.reports && openDataResponse.reports.length > 0">
|
||||||
|
<div class="col-12 col-md-6 col-lg-4 mb-4" v-for="report in visibleReports" :key="report.id">
|
||||||
|
<!-- Strict BI Card -->
|
||||||
|
<div class="card-wrapper card-space h-100">
|
||||||
|
<div class="card card-bg h-100 shadow-sm">
|
||||||
|
<div class="card-body d-flex flex-column">
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div class="category-top m-0">
|
||||||
|
<span class="badge rounded-pill text-white px-3 py-1" :class="getStatusBadge(report.status).class">
|
||||||
|
<BIIcon :name="getStatusBadge(report.status).icon" size="xs" color="white" class="me-1" />
|
||||||
|
{{ getStatusBadge(report.status).text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted small">ID: #{{ report.id }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="card-title h5 font-weight-bold">{{ report.category_name }}</h3>
|
||||||
|
|
||||||
|
<p class="card-text text-muted mb-4">
|
||||||
|
<BIIcon name="it-map-marker-circle" size="sm" class="me-1" />
|
||||||
|
{{ report.area_name || 'Area comunale non specificata' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-auto pt-3 border-top">
|
||||||
|
<div class="row text-muted small">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="fw-bold text-uppercase">Segnalato</div>
|
||||||
|
<div>{{ new Date(report.created_at).toLocaleDateString('it-IT') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 text-end">
|
||||||
|
<div class="fw-bold text-uppercase">Risolto</div>
|
||||||
|
<div>{{ new Date(report.updated_at).toLocaleDateString('it-IT') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="alert alert-warning text-center" role="alert">
|
||||||
|
Nessun dato pubblico disponibile al momento.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- BOOTSTRAP ITALIA PAGINATION -->
|
||||||
|
<nav class="pagination-wrapper justify-content-center mt-5" aria-label="Navigazione pagine open data">
|
||||||
|
<ul class="pagination">
|
||||||
|
<li class="page-item" :class="{ disabled: currentPage === 1 }">
|
||||||
|
<a class="page-link" href="#" tabindex="-1" aria-disabled="true" @click.prevent="prevPage">
|
||||||
|
<BIIcon name="it-chevron-left" size="sm" />
|
||||||
|
<span class="visually-hidden">Pagina precedente</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<span class="page-link font-weight-semibold">Pagina {{ currentPage }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="page-item" :class="{ disabled: isLastPage }">
|
||||||
|
<a class="page-link" href="#" @click.prevent="nextPage">
|
||||||
|
<span class="visually-hidden">Pagina successiva</span>
|
||||||
|
<BIIcon name="it-chevron-right" size="sm" />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MAP VIEW -->
|
||||||
|
<div v-else-if="currentView === 'map'" class="map-view-wrapper">
|
||||||
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
||||||
|
<p class="text-muted mb-0">Mappa pubblica delle segnalazioni nell'area di Bari.</p>
|
||||||
|
<span class="badge bg-light text-dark border">{{ visibleReports.length }} punti</span>
|
||||||
|
</div>
|
||||||
|
<ClientOnly>
|
||||||
|
<div ref="mapContainer" class="open-data-map rounded border"></div>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Ensures the cards inside the grid stretch to equal heights */
|
||||||
|
.card-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.card-bg {
|
||||||
|
border: 1px solid rgba(0,0,0,0.05);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.card-bg:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-data-map {
|
||||||
|
height: 520px;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.open-data-popup) {
|
||||||
|
color: #17324d;
|
||||||
|
font-family: "Titillium Web", Arial, sans-serif;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
auth: { role: 'OPERATOR' }
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Mie Assegnazioni - Operatore' })
|
||||||
|
|
||||||
|
const { getAssignedReports } = useOperator()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
|
||||||
|
const { data: reportsRes, pending } = useAsyncData(
|
||||||
|
'operator-assigned-reports',
|
||||||
|
() => getAssignedReports({ page: page.value, per_page: perPage.value }),
|
||||||
|
{ watch: [page] }
|
||||||
|
)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 font-weight-bold mb-0">Le Mie Assegnazioni</h1>
|
||||||
|
<div>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink>
|
||||||
|
<NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Coda Open</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!reportsRes?.reports || reportsRes.reports.length === 0" class="alert alert-info">
|
||||||
|
Non hai segnalazioni in carico al momento.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<ReportTable
|
||||||
|
:reports="reportsRes.reports"
|
||||||
|
detail-base-path="/operator/reports"
|
||||||
|
action-label="Apri segnalazione"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
v-if="reportsRes.total > perPage"
|
||||||
|
:current-page="page"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-items="reportsRes.total"
|
||||||
|
@page-change="page = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
auth: { role: 'OPERATOR' }
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Segnalazioni Aperte - Operatore' })
|
||||||
|
|
||||||
|
const { getOpenReports, takeReport } = useOperator()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
|
||||||
|
const { data: reportsRes, pending, refresh } = useAsyncData(
|
||||||
|
'operator-open-reports',
|
||||||
|
() => getOpenReports({ page: page.value, per_page: perPage.value }),
|
||||||
|
{ watch: [page] }
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTakeReport = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const res = await takeReport(id)
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to take report', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 font-weight-bold mb-0">Segnalazioni da prendere in carico (OPEN)</h1>
|
||||||
|
<div>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink>
|
||||||
|
<NuxtLink to="/operator/assigned" class="btn btn-outline-primary btn-sm">Le mie assegnazioni</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!reportsRes?.reports || reportsRes.reports.length === 0" class="alert alert-info">
|
||||||
|
Non ci sono segnalazioni aperte in questo momento.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<ReportTable
|
||||||
|
:reports="reportsRes.reports"
|
||||||
|
action-variant="manage"
|
||||||
|
detail-base-path="/operator/reports"
|
||||||
|
@take="handleTakeReport"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
v-if="reportsRes.total > perPage"
|
||||||
|
:current-page="page"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-items="reportsRes.total"
|
||||||
|
@page-change="page = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
auth: { role: 'OPERATOR' }
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Gestite da Altri - Operatore' })
|
||||||
|
|
||||||
|
const { getOtherOperatorReports } = useOperator()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
|
||||||
|
const { data: reportsRes, pending } = useAsyncData(
|
||||||
|
'operator-other-reports',
|
||||||
|
() => getOtherOperatorReports({ page: page.value, per_page: perPage.value }),
|
||||||
|
{ watch: [page] }
|
||||||
|
)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 font-weight-bold mb-0">Gestite da altri Operatori</h1>
|
||||||
|
<div>
|
||||||
|
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Dashboard</NuxtLink>
|
||||||
|
<NuxtLink to="/operator" class="btn btn-outline-primary btn-sm">Coda Open</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!reportsRes?.reports || reportsRes.reports.length === 0" class="alert alert-info">
|
||||||
|
Non ci sono segnalazioni gestite da altri.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<ReportTable
|
||||||
|
:reports="reportsRes.reports"
|
||||||
|
detail-base-path="/operator/reports"
|
||||||
|
action-label="Apri segnalazione"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
v-if="reportsRes.total > perPage"
|
||||||
|
:current-page="page"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-items="reportsRes.total"
|
||||||
|
@page-change="page = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Message, ReportStatus } from '~/types/api'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
auth: { role: 'OPERATOR' }
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Gestione Segnalazione - Operatore' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const reportId = Number(route.params.id)
|
||||||
|
|
||||||
|
if (!reportId) {
|
||||||
|
navigateTo('/operator')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getReportDetail, getMessages, addMessage, flagMessage } = useReports()
|
||||||
|
const { getInterventionSummary, updateReportStatus, assignTeam, getTeams, createInterventionLink, revokeInterventionLink } = useOperator()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
// Dati principali
|
||||||
|
const { data: reportData, refresh: refreshReport } = await useAsyncData(`op-report-${reportId}`, () => getReportDetail(reportId))
|
||||||
|
const { data: messagesData, refresh: refreshMessages } = await useAsyncData(`op-messages-${reportId}`, () => getMessages(reportId))
|
||||||
|
const { data: summaryData, refresh: refreshSummary } = await useAsyncData(`op-summary-${reportId}`, () => getInterventionSummary(reportId))
|
||||||
|
const { data: teamsData } = await useAsyncData('operator-teams', () => getTeams())
|
||||||
|
|
||||||
|
const report = computed(() => (reportData.value as any)?.report)
|
||||||
|
const media = computed(() => (reportData.value as any)?.media || [])
|
||||||
|
const statusHistory = computed(() => (reportData.value as any)?.history || [])
|
||||||
|
const messages = computed(() => (messagesData.value as any)?.messages || [])
|
||||||
|
const summary = computed(() => (summaryData.value as any)?.summary)
|
||||||
|
const summaryMedia = computed(() => (summaryData.value as any)?.media || [])
|
||||||
|
const teams = computed(() => teamsData.value?.teams || [])
|
||||||
|
const statusMeta: Record<string, string> = {
|
||||||
|
OPEN: 'Aperta',
|
||||||
|
UNDER_REVIEW: 'In revisione',
|
||||||
|
ASSIGNED: 'Assegnata',
|
||||||
|
IN_PROGRESS: 'In lavorazione',
|
||||||
|
RESOLVED: 'Risolta',
|
||||||
|
REJECTED: 'Respinta',
|
||||||
|
CLOSED: 'Chiusa'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Cambia Stato
|
||||||
|
const newStatus = ref<ReportStatus>('IN_PROGRESS')
|
||||||
|
const statusNote = ref('')
|
||||||
|
const isUpdatingStatus = ref(false)
|
||||||
|
|
||||||
|
// Form Assegna Squadra
|
||||||
|
const isAssignModalOpen = ref(false)
|
||||||
|
const selectedTeamId = ref<number | null>(null)
|
||||||
|
const isAssigning = ref(false)
|
||||||
|
const interventionLink = ref('')
|
||||||
|
const interventionLinkExpiresAt = ref('')
|
||||||
|
const isGeneratingInterventionLink = ref(false)
|
||||||
|
const isRevokingInterventionLink = ref(false)
|
||||||
|
const interventionLinkError = ref('')
|
||||||
|
const interventionLinkSuccess = ref('')
|
||||||
|
const isRevokeInterventionModalOpen = ref(false)
|
||||||
|
|
||||||
|
// Messaggi
|
||||||
|
const isSubmittingMessage = ref(false)
|
||||||
|
const isFlaggingMessageId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const availableStatuses: { value: ReportStatus, label: string }[] = [
|
||||||
|
{ value: 'OPEN', label: 'Aperta' },
|
||||||
|
{ value: 'UNDER_REVIEW', label: 'In Revisione' },
|
||||||
|
{ value: 'ASSIGNED', label: 'Assegnata' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'In Lavorazione' },
|
||||||
|
{ value: 'RESOLVED', label: 'Risolta' },
|
||||||
|
{ value: 'REJECTED', label: 'Rifiutata' },
|
||||||
|
{ value: 'CLOSED', label: 'Chiusa' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Inizializza lo stato nel form
|
||||||
|
watch(() => report.value?.status, (val) => {
|
||||||
|
if (val) newStatus.value = val as ReportStatus
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const handleStatusUpdate = async () => {
|
||||||
|
isUpdatingStatus.value = true
|
||||||
|
try {
|
||||||
|
const res = await updateReportStatus(reportId, {
|
||||||
|
status: newStatus.value,
|
||||||
|
note: statusNote.value || undefined
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
statusNote.value = ''
|
||||||
|
await Promise.all([refreshReport(), refreshSummary()])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update status', e)
|
||||||
|
} finally {
|
||||||
|
isUpdatingStatus.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAssignTeam = async () => {
|
||||||
|
if (!selectedTeamId.value) return
|
||||||
|
isAssigning.value = true
|
||||||
|
try {
|
||||||
|
const res = await assignTeam(reportId, {
|
||||||
|
team_id: selectedTeamId.value
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
isAssignModalOpen.value = false
|
||||||
|
selectedTeamId.value = null
|
||||||
|
await Promise.all([refreshReport(), refreshSummary()])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to assign team', e)
|
||||||
|
} finally {
|
||||||
|
isAssigning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyInterventionLink = async () => {
|
||||||
|
if (!interventionLink.value || !navigator?.clipboard) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(interventionLink.value)
|
||||||
|
interventionLinkSuccess.value = 'Link copiato negli appunti.'
|
||||||
|
interventionLinkError.value = ''
|
||||||
|
} catch {
|
||||||
|
interventionLinkError.value = 'Copia automatica non riuscita.'
|
||||||
|
interventionLinkSuccess.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateInterventionLink = async () => {
|
||||||
|
isGeneratingInterventionLink.value = true
|
||||||
|
interventionLinkError.value = ''
|
||||||
|
interventionLinkSuccess.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await createInterventionLink(reportId)
|
||||||
|
if (res.ok) {
|
||||||
|
interventionLink.value = res.url
|
||||||
|
interventionLinkExpiresAt.value = res.expires_at
|
||||||
|
interventionLinkSuccess.value = 'Link intervento generato.'
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to create intervention link', e)
|
||||||
|
interventionLinkError.value = e?.data?.message || 'Generazione link non riuscita.'
|
||||||
|
} finally {
|
||||||
|
isGeneratingInterventionLink.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openRevokeInterventionLinkModal = () => {
|
||||||
|
if (!interventionLink.value) return
|
||||||
|
isRevokeInterventionModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeRevokeInterventionLinkModal = () => {
|
||||||
|
if (isRevokingInterventionLink.value) return
|
||||||
|
isRevokeInterventionModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevokeInterventionLink = async () => {
|
||||||
|
if (!interventionLink.value) return
|
||||||
|
|
||||||
|
isRevokingInterventionLink.value = true
|
||||||
|
interventionLinkError.value = ''
|
||||||
|
interventionLinkSuccess.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await revokeInterventionLink(reportId)
|
||||||
|
if (res.ok) {
|
||||||
|
interventionLink.value = ''
|
||||||
|
interventionLinkExpiresAt.value = ''
|
||||||
|
interventionLinkSuccess.value = 'Link intervento revocato.'
|
||||||
|
isRevokeInterventionModalOpen.value = false
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to revoke intervention link', e)
|
||||||
|
interventionLinkError.value = e?.data?.message || 'Revoca link non riuscita.'
|
||||||
|
} finally {
|
||||||
|
isRevokingInterventionLink.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendMessage = async (msgText: string, files: File[]) => {
|
||||||
|
if (!msgText.trim() && files.length === 0) return
|
||||||
|
isSubmittingMessage.value = true
|
||||||
|
try {
|
||||||
|
await addMessage(reportId, { body: msgText })
|
||||||
|
await refreshMessages()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send message', e)
|
||||||
|
} finally {
|
||||||
|
isSubmittingMessage.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canFlagMessage = (message: Message) => {
|
||||||
|
if (!user.value) return false
|
||||||
|
return message.user_id !== user.value.id && message.user_id === report.value?.user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFlagMessage = async (message: Message) => {
|
||||||
|
if (!canFlagMessage(message)) return
|
||||||
|
|
||||||
|
isFlaggingMessageId.value = message.id
|
||||||
|
try {
|
||||||
|
await flagMessage(reportId, message.id, {
|
||||||
|
reason: 'Messaggio segnalato da operatore responsabile per verifica moderazione',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to flag message', e)
|
||||||
|
} finally {
|
||||||
|
isFlaggingMessageId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return '-'
|
||||||
|
return new Date(dateString).toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestCitizenMessage = computed(() => {
|
||||||
|
const reportUserId = report.value?.user_id
|
||||||
|
if (!reportUserId) return null
|
||||||
|
|
||||||
|
return [...messages.value]
|
||||||
|
.reverse()
|
||||||
|
.find((message) => message.user_id === reportUserId) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedInterventionLinkExpiry = computed(() =>
|
||||||
|
interventionLinkExpiresAt.value ? formatDate(interventionLinkExpiresAt.value) : ''
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="report">
|
||||||
|
<div class="report-hero py-4">
|
||||||
|
<div class="container">
|
||||||
|
<NuxtLink to="/operator/assigned" class="btn btn-sm btn-link text-decoration-none ps-0 mb-2">
|
||||||
|
← Torna alle assegnazioni
|
||||||
|
</NuxtLink>
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<div class="report-hero-meta mb-2">
|
||||||
|
<span class="report-id">Intervento #{{ reportId }}</span>
|
||||||
|
<StatusBadge :status="report.status" />
|
||||||
|
</div>
|
||||||
|
<h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1>
|
||||||
|
<p class="mb-0 report-hero-address">{{ report.address }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="report-hero-stats">
|
||||||
|
<div class="hero-stat">
|
||||||
|
<span class="hero-stat-label">Stato</span>
|
||||||
|
<strong>{{ statusMeta[report.status] || report.status }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stat">
|
||||||
|
<span class="hero-stat-label">Aperta</span>
|
||||||
|
<strong>{{ formatDate(report.created_at) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-12 col-xl-7">
|
||||||
|
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="report-section-head mb-3">
|
||||||
|
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
|
||||||
|
<h2 class="h6 mb-0">Dettagli segnalazione</h2>
|
||||||
|
</div>
|
||||||
|
<dl class="report-detail-grid mb-0">
|
||||||
|
<div>
|
||||||
|
<dt>Indirizzo</dt>
|
||||||
|
<dd>{{ report.address }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Coordinate</dt>
|
||||||
|
<dd>{{ report.latitude }}, {{ report.longitude }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Area</dt>
|
||||||
|
<dd>{{ report.area_name || 'Non specificata' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Operatore assegnato</dt>
|
||||||
|
<dd>{{ report.assigned_operator_id ? `#${report.assigned_operator_id}` : 'Non assegnato' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="report-description mt-4">
|
||||||
|
<h3 class="h6 mb-2">Descrizione</h3>
|
||||||
|
<p class="mb-0 text-break">{{ report.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="media.length > 0">
|
||||||
|
<h3 class="h6 mt-4 mb-3">Allegati del cittadino</h3>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div v-for="m in media" :key="m.id" class="col-4 col-md-3">
|
||||||
|
<img v-if="m.media_kind === 'IMAGE'" :src="runtimeConfig.public.api + m.url" class="img-fluid rounded object-fit-cover shadow-sm report-media-frame" style="aspect-ratio: 1;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="report-chat-header mb-3">
|
||||||
|
<div>
|
||||||
|
<div class="report-section-head mb-1">
|
||||||
|
<BIIcon name="it-comment" size="sm" color="primary" class="me-2" />
|
||||||
|
<h2 class="h6 mb-0">Comunicazioni con cittadino</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Chat unica pratica. Mantieni aggiornamenti chiari e brevi.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-light text-dark border">{{ messages.length }} messaggi</span>
|
||||||
|
</div>
|
||||||
|
<MessageList
|
||||||
|
:messages="messages"
|
||||||
|
:can-flag-message="canFlagMessage"
|
||||||
|
:is-flagging-message-id="isFlaggingMessageId"
|
||||||
|
@flag="handleFlagMessage"
|
||||||
|
/>
|
||||||
|
<MessageComposer @send="handleSendMessage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-xl-5">
|
||||||
|
<div class="report-sidebar">
|
||||||
|
<div class="card card-bg border-0 shadow-sm mb-4" v-if="summary">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6 mb-3">Quadro operativo</h2>
|
||||||
|
<ul class="report-summary-list list-unstyled mb-4">
|
||||||
|
<li>
|
||||||
|
<span>Operatore</span>
|
||||||
|
<strong>{{ summary.assigned_operator_id ? `#${summary.assigned_operator_id}` : 'Nessuno' }}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>Team disponibili</span>
|
||||||
|
<strong>{{ teams.length }}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>Allegati</span>
|
||||||
|
<strong>{{ summaryMedia.length || media.length }}</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="report-description">
|
||||||
|
<h3 class="h6 mb-2">Ultimo messaggio cittadino</h3>
|
||||||
|
<p class="small mb-0">
|
||||||
|
{{ latestCitizenMessage?.body || 'Nessun messaggio del cittadino disponibile.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body pt-0">
|
||||||
|
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="teams.length === 0">
|
||||||
|
Assegna team
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6 mb-3">Link intervento pubblico</h2>
|
||||||
|
<p class="text-muted small mb-3">Genera link temporaneo per team esterno. Nuova generazione ruota link esistente.</p>
|
||||||
|
|
||||||
|
<div v-if="interventionLinkError" class="alert alert-danger py-2" role="alert">
|
||||||
|
{{ interventionLinkError }}
|
||||||
|
</div>
|
||||||
|
<div v-if="interventionLinkSuccess" class="alert alert-success py-2" role="alert">
|
||||||
|
{{ interventionLinkSuccess }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="interventionLink">
|
||||||
|
<label class="form-label">URL attivo</label>
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input type="text" class="form-control" :value="interventionLink" readonly />
|
||||||
|
<button type="button" class="btn btn-outline-primary" @click="copyInterventionLink">
|
||||||
|
Copia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
Scade: {{ formattedInterventionLinkExpiry || '-' }}
|
||||||
|
</p>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-primary" :disabled="isGeneratingInterventionLink" @click="handleCreateInterventionLink">
|
||||||
|
<span v-if="isGeneratingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Rigenera link
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" :disabled="isRevokingInterventionLink" @click="openRevokeInterventionLinkModal">
|
||||||
|
<span v-if="isRevokingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Revoca link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-primary w-100"
|
||||||
|
:disabled="isGeneratingInterventionLink"
|
||||||
|
@click="handleCreateInterventionLink"
|
||||||
|
>
|
||||||
|
<span v-if="isGeneratingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Genera link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6 mb-3">Aggiorna stato</h2>
|
||||||
|
<form @submit.prevent="handleStatusUpdate">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Nuovo Stato</label>
|
||||||
|
<select class="form-select" v-model="newStatus">
|
||||||
|
<option v-for="st in availableStatuses" :key="st.value" :value="st.value">
|
||||||
|
{{ st.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Nota (visibile nella cronologia)</label>
|
||||||
|
<textarea class="form-control" v-model="statusNote" rows="2" placeholder="Motivazione o dettaglio..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100" :disabled="isUpdatingStatus || newStatus === report.status">
|
||||||
|
<span v-if="isUpdatingStatus" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Aggiorna Stato
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bg border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6 mb-3">Cronologia eventi</h2>
|
||||||
|
<div v-if="statusHistory.length === 0" class="small text-muted">Nessun evento registrato.</div>
|
||||||
|
<ul class="list-unstyled mb-0 ms-2">
|
||||||
|
<li v-for="item in statusHistory" :key="item.id" class="mb-3 border-start border-primary border-3 ps-3">
|
||||||
|
<small class="text-muted d-block">{{ formatDate(item.created_at) }}</small>
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-1">
|
||||||
|
<StatusBadge :status="item.old_status" />
|
||||||
|
<BIIcon name="it-arrow-right" size="xs" />
|
||||||
|
<StatusBadge :status="item.new_status" />
|
||||||
|
</div>
|
||||||
|
<div v-if="item.note" class="small mt-1 bg-light p-1 rounded">{{ item.note }}</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modale Assegnazione -->
|
||||||
|
<div v-if="isAssignModalOpen" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
|
||||||
|
<div v-if="isAssignModalOpen" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Gestione Intervento</h5>
|
||||||
|
<button type="button" class="btn-close" @click="isAssignModalOpen = false"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form @submit.prevent="handleAssignTeam">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Team incaricato</label>
|
||||||
|
<select v-model.number="selectedTeamId" class="form-select" required>
|
||||||
|
<option :value="null">Seleziona team</option>
|
||||||
|
<option v-for="team in teams" :key="team.id" :value="team.id">
|
||||||
|
{{ team.name }}<template v-if="team.category_name"> · {{ team.category_name }}</template>
|
||||||
|
<template v-if="team.area_name"> · {{ team.area_name }}</template>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<small v-if="teams.length === 0" class="form-text text-danger">
|
||||||
|
Nessun team attivo disponibile.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="isAssignModalOpen = false">Annulla</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="isAssigning || !selectedTeamId">
|
||||||
|
<span v-if="isAssigning" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Salva Assegnazione
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
:open="isRevokeInterventionModalOpen"
|
||||||
|
title="Revoca link intervento"
|
||||||
|
message="Vuoi revocare link pubblico per team esterno? Link non sara piu utilizzabile."
|
||||||
|
confirm-label="Revoca link"
|
||||||
|
confirm-class="btn-danger"
|
||||||
|
:pending="isRevokingInterventionLink"
|
||||||
|
@cancel="closeRevokeInterventionLinkModal"
|
||||||
|
@confirm="handleRevokeInterventionLink"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.report-hero {
|
||||||
|
background: #f5f7f9;
|
||||||
|
border-bottom: 1px solid #d9e3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-hero-meta,
|
||||||
|
.report-section-head {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-hero-meta {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-id {
|
||||||
|
color: #5c6f82;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-hero-address {
|
||||||
|
color: #455b71;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-hero-stats {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9e3ee;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stat {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stat-label,
|
||||||
|
.report-detail-grid dt {
|
||||||
|
color: #5c6f82;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-detail-grid dd {
|
||||||
|
color: #17324d;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-description {
|
||||||
|
background: #f5f7f9;
|
||||||
|
border: 1px solid #e4ebf1;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-chat-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-summary-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-summary-list li {
|
||||||
|
align-items: baseline;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-media-frame {
|
||||||
|
border: 1px solid #d9e3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-fit-cover {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.report-detail-grid,
|
||||||
|
.report-hero-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
title: 'Privacy Policy - Visione',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- HERO SECTION -->
|
||||||
|
<div class="it-hero-wrapper it-primary">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 text-center">
|
||||||
|
<div class="it-hero-text-wrapper p-4">
|
||||||
|
<h1 class="text-white font-weight-bold">Informativa sulla Privacy</h1>
|
||||||
|
<p class="text-white mt-3 lead font-weight-semibold">
|
||||||
|
Trattamento dei dati personali ai sensi del Regolamento (UE) 2016/679 (GDPR).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENUTO PRINCIPALE -->
|
||||||
|
<div class="container my-5 py-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-lg-8 offset-lg-2">
|
||||||
|
|
||||||
|
<div class="alert alert-info mb-5" role="alert">
|
||||||
|
La presente informativa descrive le modalità di gestione della piattaforma <strong>Visione</strong> in riferimento al trattamento dei dati personali degli utenti che la consultano e vi si registrano.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="h3 font-weight-bold mb-3">1. Titolare del Trattamento</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Ai fini dell'attuale istanza dimostrativa, il Titolare del trattamento dei dati coincide con gli sviluppatori del progetto afferenti all'Università degli Studi di Bari "Aldo Moro" (Progetto Accademico). In uno scenario di produzione, il Titolare del trattamento sarà il Comune o l'Ente della Pubblica Amministrazione che adotta e ospita il software.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h3 font-weight-bold mb-3">2. Quali dati raccogliamo</h2>
|
||||||
|
<p class="text-muted mb-2">Per garantire il corretto funzionamento della piattaforma e prevenire abusi, vengono raccolte le seguenti categorie di dati:</p>
|
||||||
|
<ul class="mb-4 text-muted">
|
||||||
|
<li><strong>Dati anagrafici e di contatto:</strong> Nome, cognome, Codice Fiscale, indirizzo email, numero di telefono (opzionale), data e luogo di nascita.</li>
|
||||||
|
<li><strong>Dati di geolocalizzazione:</strong> Coordinate geografiche (Latitudine e Longitudine) e indirizzo associato alla segnalazione inviata dall'utente.</li>
|
||||||
|
<li><strong>Contenuti multimediali:</strong> Fotografie e descrizioni testuali caricate spontaneamente dall'utente per documentare il disservizio.</li>
|
||||||
|
<li><strong>Dati di navigazione:</strong> Indirizzi IP e timestamp generati automaticamente dai log di sistema per finalità di sicurezza e prevenzione frodi (accesso OTP).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="h3 font-weight-bold mb-3">3. Finalità del Trattamento</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
I dati forniti verranno trattati esclusivamente per le seguenti finalità:
|
||||||
|
<br>- Consentire la registrazione e l'autenticazione sicura (incluso login Passwordless tramite OTP).
|
||||||
|
<br>- Gestire la presa in carico e la risoluzione delle segnalazioni urbane da parte delle squadre operative.
|
||||||
|
<br>- Fornire comunicazioni di servizio relative allo stato di avanzamento delle segnalazioni.
|
||||||
|
<br>- Attribuire punteggi virtuali all'utente nell'ambito del sistema di <em>Gamification</em> volto a incentivare la cittadinanza attiva.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h3 font-weight-bold mb-3">4. Dati Pubblici e Portale Open Data</h2>
|
||||||
|
<div class="card card-bg border-start border-info border-4 mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-0 text-muted">
|
||||||
|
Visione promuove la trasparenza amministrativa. Tutte le segnalazioni chiuse o risolte confluiscono nel nostro Portale Open Data.
|
||||||
|
<strong>Prima della pubblicazione, i dati vengono severamente anonimizzati.</strong> Nessun dato personale (nome, cognome, codice fiscale o chat private) viene esposto. Vengono resi pubblici unicamente: Categoria dell'intervento, area geografica (es. quartiere), coordinate, e data di risoluzione.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="h3 font-weight-bold mb-3">5. Misure di Sicurezza</h2>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Il trattamento dei dati è realizzato tramite strumenti informatici e telematici, con logiche strettamente correlate alle finalità sopra evidenziate. Il backend (sviluppato in ambiente C++ ad alte prestazioni) adotta protocolli sicuri di trasmissione e tecniche di cifratura avanzata per lo stoccaggio delle credenziali.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="h3 font-weight-bold mb-3">6. Diritti dell'interessato</h2>
|
||||||
|
<p class="text-muted mb-5">
|
||||||
|
Ai sensi degli artt. 15-22 del GDPR, l'utente ha diritto di:
|
||||||
|
<br>- Ottenere la conferma dell'esistenza di dati personali che lo riguardano.
|
||||||
|
<br>- Chiedere la rettifica, l'aggiornamento o la cancellazione del proprio account e dei dati connessi.
|
||||||
|
<br>- Opporti al trattamento o chiederne la limitazione.
|
||||||
|
<br>Per esercitare tali diritti nell'ambito di questa istanza accademica, è possibile contattare gli sviluppatori o richiedere l'eliminazione direttamente dalla propria area riservata.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Profilo - Visione' })
|
||||||
|
|
||||||
|
const { user, fetchRewards, logout } = useAuth()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
|
||||||
|
const { data: rewardsData } = await useAsyncData(
|
||||||
|
'profile-rewards',
|
||||||
|
() => fetchRewards({ page: page.value, per_page: perPage.value }),
|
||||||
|
{ watch: [page] }
|
||||||
|
)
|
||||||
|
|
||||||
|
const rewards = computed(() => rewardsData.value?.reward_events || [])
|
||||||
|
const totalPoints = computed(() => rewardsData.value?.points || 0)
|
||||||
|
const totalRewards = computed(() => rewards.value.length)
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) =>
|
||||||
|
new Date(dateString).toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="bg-light border-bottom py-4 mb-4">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="h3 font-weight-bold mb-1">Profilo</h1>
|
||||||
|
<p class="text-muted mb-0">Dati account, punti cittadino e impostazioni personali.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mb-5">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="h5 card-title mb-0">Dati personali</h2>
|
||||||
|
<NuxtLink to="/settings/profile" class="btn btn-outline-primary btn-sm">
|
||||||
|
Modifica
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<small class="text-muted d-block">Nome</small>
|
||||||
|
<strong>{{ user?.first_name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<small class="text-muted d-block">Cognome</small>
|
||||||
|
<strong>{{ user?.last_name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<small class="text-muted d-block">Email</small>
|
||||||
|
<strong>{{ user?.email }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<small class="text-muted d-block">Telefono</small>
|
||||||
|
<strong>{{ user?.phone || 'Non fornito' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-0">
|
||||||
|
<small class="text-muted d-block">Codice fiscale</small>
|
||||||
|
<strong>{{ user?.fiscal_code }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-0">
|
||||||
|
<small class="text-muted d-block">Ruolo</small>
|
||||||
|
<span class="badge bg-info">{{ user?.role }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bg">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="h5 card-title mb-0">Cronologia punti</h2>
|
||||||
|
<span class="badge bg-primary">{{ totalPoints }} punti</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rewards.length === 0" class="alert alert-info mb-0" role="alert">
|
||||||
|
Nessun punto registrato.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Data</th>
|
||||||
|
<th scope="col">Segnalazione</th>
|
||||||
|
<th scope="col">Motivo</th>
|
||||||
|
<th scope="col" class="text-end">Punti</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="reward in rewards" :key="reward.id">
|
||||||
|
<td class="text-muted">{{ formatDate(reward.created_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<NuxtLink :to="`/reports/${reward.report_id}`">#{{ reward.report_id }}</NuxtLink>
|
||||||
|
</td>
|
||||||
|
<td>{{ reward.reason }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<span class="badge bg-success">+{{ reward.points }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
v-if="totalRewards > perPage"
|
||||||
|
:current-page="page"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-items="totalRewards"
|
||||||
|
@page-change="page = $event"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="card card-bg mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6 card-title mb-3">Azioni account</h2>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<NuxtLink to="/reports/new" class="btn btn-primary">Nuova segnalazione</NuxtLink>
|
||||||
|
<NuxtLink to="/notifications" class="btn btn-outline-primary">Notifiche</NuxtLink>
|
||||||
|
<NuxtLink to="/auth/change-password" class="btn btn-outline-secondary">Cambia password</NuxtLink>
|
||||||
|
<button type="button" class="btn btn-outline-danger" @click="logout">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6 card-title mb-2">Punti cittadino</h2>
|
||||||
|
<div class="display-6 text-primary font-weight-bold mb-2">{{ totalPoints }}</div>
|
||||||
|
<p class="small text-muted mb-0">
|
||||||
|
I punti vengono accreditati sulle segnalazioni concluse con esito positivo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Dettaglio Segnalazione - Visione' })
|
||||||
|
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { getReportDetail, getMessages, addMessage, addFeedback } = useReports()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const reportId = Number(route.params.id)
|
||||||
|
if (!reportId) {
|
||||||
|
navigateTo('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch dati
|
||||||
|
const { data: reportData, refresh: refreshReport } = await useAsyncData(`report-${reportId}`, () =>
|
||||||
|
getReportDetail(reportId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: messagesData, refresh: refreshMessages } = await useAsyncData(`messages-${reportId}`, () =>
|
||||||
|
getMessages(reportId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const report = computed(() => (reportData.value as any)?.report)
|
||||||
|
const media = computed(() => (reportData.value as any)?.media || [])
|
||||||
|
const statusHistory = computed(() => (reportData.value as any)?.history || [])
|
||||||
|
const messages = computed(() => (messagesData.value as any)?.messages || [])
|
||||||
|
|
||||||
|
const newMessage = ref('')
|
||||||
|
const isSubmittingMessage = ref(false)
|
||||||
|
const isFeedbackOpen = ref(false)
|
||||||
|
const feedbackRating = ref(5)
|
||||||
|
const feedbackComment = ref('')
|
||||||
|
const isSubmittingFeedback = ref(false)
|
||||||
|
const selectedMediaIndex = ref(0)
|
||||||
|
const isGalleryOpen = ref(false)
|
||||||
|
|
||||||
|
// Invia messaggio
|
||||||
|
const handleSendMessage = async (msgText: string, files: File[]) => {
|
||||||
|
if (!msgText.trim() && files.length === 0) return
|
||||||
|
|
||||||
|
isSubmittingMessage.value = true
|
||||||
|
try {
|
||||||
|
// Gestione mock di file, nella realtà si usa FormData o simle
|
||||||
|
await addMessage(reportId, { body: msgText })
|
||||||
|
await refreshMessages()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending message:', error)
|
||||||
|
} finally {
|
||||||
|
isSubmittingMessage.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invia feedback (solo se risolta)
|
||||||
|
const handleSubmitFeedback = async () => {
|
||||||
|
isSubmittingFeedback.value = true
|
||||||
|
try {
|
||||||
|
await addFeedback(reportId, {
|
||||||
|
rating: feedbackRating.value,
|
||||||
|
comment: feedbackComment.value || undefined,
|
||||||
|
})
|
||||||
|
isFeedbackOpen.value = false
|
||||||
|
feedbackRating.value = 5
|
||||||
|
feedbackComment.value = ''
|
||||||
|
await refreshReport()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting feedback:', error)
|
||||||
|
} finally {
|
||||||
|
isSubmittingFeedback.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = computed(() => report.value?.user_id === user.value?.id)
|
||||||
|
const canFeedback = computed(() => report.value?.status === 'RESOLVED' || report.value?.status === 'CLOSED')
|
||||||
|
const statusMeta: Record<string, { label: string; tone: string }> = {
|
||||||
|
OPEN: { label: 'Aperta', tone: 'info' },
|
||||||
|
UNDER_REVIEW: { label: 'In revisione', tone: 'warning' },
|
||||||
|
ASSIGNED: { label: 'Assegnata', tone: 'info' },
|
||||||
|
IN_PROGRESS: { label: 'In lavorazione', tone: 'primary' },
|
||||||
|
RESOLVED: { label: 'Risolta', tone: 'success' },
|
||||||
|
REJECTED: { label: 'Respinta', tone: 'danger' },
|
||||||
|
CLOSED: { label: 'Chiusa', tone: 'secondary' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return new Date(dateString).toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="report">
|
||||||
|
<div class="report-hero py-4 py-lg-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-end g-3">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<div class="report-hero-meta mb-2">
|
||||||
|
<span class="report-id">Segnalazione #{{ report.id }}</span>
|
||||||
|
<StatusBadge :status="report.status" />
|
||||||
|
</div>
|
||||||
|
<h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1>
|
||||||
|
<p class="mb-0 report-hero-address">{{ report.address }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="report-hero-stats">
|
||||||
|
<div class="hero-stat">
|
||||||
|
<span class="hero-stat-label">Stato</span>
|
||||||
|
<strong>{{ statusMeta[report.status]?.label || report.status }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stat">
|
||||||
|
<span class="hero-stat-label">Creata</span>
|
||||||
|
<strong>{{ formatDate(report.created_at) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-12 col-xl-8">
|
||||||
|
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="report-section-head mb-3">
|
||||||
|
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
|
||||||
|
<h2 class="h6 mb-0">Posizione e dettagli</h2>
|
||||||
|
</div>
|
||||||
|
<dl class="report-detail-grid mb-0">
|
||||||
|
<div>
|
||||||
|
<dt>Indirizzo</dt>
|
||||||
|
<dd>{{ report.address }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Coordinate</dt>
|
||||||
|
<dd>{{ report.latitude }}, {{ report.longitude }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="report.area_name">
|
||||||
|
<dt>Area</dt>
|
||||||
|
<dd>{{ report.area_name }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Ultimo aggiornamento</dt>
|
||||||
|
<dd>{{ formatDate(report.updated_at || report.created_at) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="report-description mt-4">
|
||||||
|
<h3 class="h6 mb-2">Descrizione</h3>
|
||||||
|
<p class="mb-0 text-break">{{ report.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="media.length > 0" class="card card-bg border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="report-section-head mb-3">
|
||||||
|
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
|
||||||
|
<h2 class="h6 mb-0">Allegati ({{ media.length }})</h2>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div v-for="(m, idx) in media" :key="m.id" class="col-6 col-md-4 mb-3">
|
||||||
|
<div v-if="m.media_kind === 'IMAGE'" class="ratio ratio-1x1 bg-light rounded overflow-hidden report-media-frame">
|
||||||
|
<img :src="runtimeConfig.public.api + m.url" :alt="`Allegato ${Number(idx) + 1}`" class="object-fit-cover" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="ratio ratio-1x1 bg-dark rounded d-flex align-items-center justify-content-center report-media-frame">
|
||||||
|
<BIIcon name="it-video" size="xl" color="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="statusHistory.length > 0" class="card card-bg border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="report-section-head mb-4">
|
||||||
|
<BIIcon name="it-history" size="sm" color="primary" class="me-2" />
|
||||||
|
<h2 class="h6 mb-0">Cronologia</h2>
|
||||||
|
</div>
|
||||||
|
<div class="timeline">
|
||||||
|
<div v-for="item in statusHistory" :key="item.id" class="timeline-item mb-3">
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="timeline-marker"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<small class="text-muted">{{ formatDate(item.created_at) }}</small>
|
||||||
|
<div class="small font-weight-semibold d-flex align-items-center gap-2 mt-1">
|
||||||
|
<StatusBadge :status="item.old_status" />
|
||||||
|
<BIIcon name="it-arrow-right" size="xs" />
|
||||||
|
<StatusBadge :status="item.new_status" />
|
||||||
|
</div>
|
||||||
|
<small v-if="item.note" class="text-muted d-block">{{ item.note }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-bg border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="report-chat-header mb-3">
|
||||||
|
<div>
|
||||||
|
<div class="report-section-head mb-1">
|
||||||
|
<BIIcon name="it-comment" size="sm" color="primary" class="me-2" />
|
||||||
|
<h2 class="h6 mb-0">Chat segnalazione</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Usa chat per chiarimenti, aggiornamenti operativi e richieste rapide.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-light text-dark border">{{ messages.length }} messaggi</span>
|
||||||
|
</div>
|
||||||
|
<MessageList :messages="messages" />
|
||||||
|
<MessageComposer @send="handleSendMessage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-xl-4">
|
||||||
|
<div class="report-sidebar">
|
||||||
|
<div class="card card-bg border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6 mb-3">Riepilogo pratica</h2>
|
||||||
|
<ul class="report-summary-list list-unstyled mb-0">
|
||||||
|
<li>
|
||||||
|
<span>Cittadino proprietario</span>
|
||||||
|
<strong>{{ isOwner ? 'Sì' : 'No' }}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>Stato corrente</span>
|
||||||
|
<strong>{{ statusMeta[report.status]?.label || report.status }}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>Canale</span>
|
||||||
|
<strong>Portale Visione</strong>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canFeedback && isOwner" class="card card-bg border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="h6 mb-3">Valuta intervento</h2>
|
||||||
|
<p class="text-muted small mb-3">Lascia valutazione finale quando intervento è concluso.</p>
|
||||||
|
<button
|
||||||
|
v-if="!isFeedbackOpen"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary w-100"
|
||||||
|
@click="isFeedbackOpen = true"
|
||||||
|
>
|
||||||
|
Valuta intervento
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Valutazione</label>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button v-for="n in 5" :key="n" type="button" class="btn btn-link p-0"
|
||||||
|
@click="feedbackRating = n" :aria-label="`${n} stelle`">
|
||||||
|
<BIIcon
|
||||||
|
:name="n <= feedbackRating ? 'it-star-full' : 'it-star-outline'"
|
||||||
|
size="lg"
|
||||||
|
:color="n <= feedbackRating ? 'warning' : 'primary'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea
|
||||||
|
v-model="feedbackComment"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Commento opzionale"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="button" class="btn btn-primary" @click="handleSubmitFeedback" :disabled="isSubmittingFeedback">
|
||||||
|
<span v-if="isSubmittingFeedback" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Invia
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @click="isFeedbackOpen = false">
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="container my-5 text-center">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Caricamento...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.report-hero {
|
||||||
|
background: #f5f7f9;
|
||||||
|
border-bottom: 1px solid #d9e3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-hero-meta,
|
||||||
|
.report-section-head {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-hero-meta {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-id {
|
||||||
|
color: #5c6f82;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-hero-address {
|
||||||
|
color: #455b71;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-hero-stats {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9e3ee;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stat {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stat-label,
|
||||||
|
.report-detail-grid dt {
|
||||||
|
color: #5c6f82;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-detail-grid dd {
|
||||||
|
color: #17324d;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-description {
|
||||||
|
background: #f5f7f9;
|
||||||
|
border: 1px solid #e4ebf1;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-media-frame {
|
||||||
|
border: 1px solid #d9e3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-chat-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-summary-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-summary-list li {
|
||||||
|
align-items: baseline;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marker {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #006f9d;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:not(:last-child) .timeline-marker::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 20px;
|
||||||
|
width: 2px;
|
||||||
|
height: calc(100% + 20px);
|
||||||
|
background-color: #006f9d;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratio {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-fit-cover {
|
||||||
|
object-fit: cover !important;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.report-detail-grid,
|
||||||
|
.report-hero-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Report } from '~/types/api'
|
||||||
|
import { isWithinBariBounds } from '~/utils/bari'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Le mie segnalazioni - Visione' })
|
||||||
|
|
||||||
|
const { getReports } = useReports()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
const filterStatus = ref('')
|
||||||
|
|
||||||
|
const { data: reportsRes, pending } = useAsyncData('citizen-reports-list', () => getReports({}))
|
||||||
|
|
||||||
|
const reports = computed<Report[]>(() =>
|
||||||
|
(reportsRes.value?.reports || []).filter((report) => isWithinBariBounds(report.latitude, report.longitude))
|
||||||
|
)
|
||||||
|
const filteredReports = computed(() => {
|
||||||
|
if (!filterStatus.value) return reports.value
|
||||||
|
if (filterStatus.value === 'IN_PROGRESS') {
|
||||||
|
return reports.value.filter((report) => report.status === 'IN_PROGRESS' || report.status === 'ASSIGNED')
|
||||||
|
}
|
||||||
|
return reports.value.filter((report) => report.status === filterStatus.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginatedReports = computed(() => {
|
||||||
|
const start = (page.value - 1) * perPage.value
|
||||||
|
return filteredReports.value.slice(start, start + perPage.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleStatusFilter = (status: string) => {
|
||||||
|
filterStatus.value = status === 'all' ? '' : status
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="bg-light border-bottom py-4 mb-4">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 font-weight-bold mb-1">Le mie segnalazioni</h1>
|
||||||
|
<p class="text-muted mb-0">Consulta stato, cronologia e conversazioni con gli operatori.</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/reports/new" class="btn btn-primary">
|
||||||
|
<BIIcon name="it-plus-circle" size="sm" color="white" class="me-2" />
|
||||||
|
Nuova segnalazione
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mb-5">
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
|
||||||
|
<div class="btn-group" role="group" aria-label="Filtri segnalazioni">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="filterStatus === '' ? 'btn-primary' : 'btn-outline-primary'"
|
||||||
|
@click="handleStatusFilter('all')"
|
||||||
|
>
|
||||||
|
Tutte
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="filterStatus === 'OPEN' ? 'btn-primary' : 'btn-outline-primary'"
|
||||||
|
@click="handleStatusFilter('OPEN')"
|
||||||
|
>
|
||||||
|
Aperte
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="filterStatus === 'IN_PROGRESS' ? 'btn-primary' : 'btn-outline-primary'"
|
||||||
|
@click="handleStatusFilter('IN_PROGRESS')"
|
||||||
|
>
|
||||||
|
In corso
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="filterStatus === 'RESOLVED' ? 'btn-primary' : 'btn-outline-primary'"
|
||||||
|
@click="handleStatusFilter('RESOLVED')"
|
||||||
|
>
|
||||||
|
Risolte
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Caricamento...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredReports.length === 0" class="alert alert-info" role="alert">
|
||||||
|
Nessuna segnalazione trovata.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<ReportTable :reports="paginatedReports" action-label="Apri segnalazione" />
|
||||||
|
<Pagination
|
||||||
|
v-if="filteredReports.length > perPage"
|
||||||
|
:current-page="page"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-items="filteredReports.length"
|
||||||
|
@page-change="page = $event"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CreateReportRequest } from '~/types/api'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Nuova segnalazione - Visione' })
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { createReport, uploadMedia, getCategories } = useReports()
|
||||||
|
const { data: categoriesData } = await useAsyncData('categories', () => getCategories())
|
||||||
|
const categories = computed(() => categoriesData.value?.categories || [])
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const handleReportSubmit = async (reportData: CreateReportRequest, mediaFiles: File[]) => {
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await createReport({
|
||||||
|
...reportData,
|
||||||
|
media: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok && response.report_id) {
|
||||||
|
if (mediaFiles.length > 0) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
mediaFiles.forEach((file) => formData.append('files', file))
|
||||||
|
await uploadMedia(response.report_id, formData)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Media upload failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push(`/reports/${response.report_id}`)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage.value = error.data?.message || 'Errore durante la creazione della segnalazione. Riprova.'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="it-hero-wrapper it-primary-bg hero-compact">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="it-hero-text-wrapper p-4">
|
||||||
|
<h1 class="text-white font-weight-bold">Nuova segnalazione</h1>
|
||||||
|
<p class="text-white mt-2 lead">
|
||||||
|
Descrivi il problema urbano, indica la posizione e allega eventuali media.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-xl-10">
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
<button type="button" class="btn-close" aria-label="Chiudi" @click="errorMessage = ''"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReportForm :categories="categories" :is-loading="isLoading" @submit="handleReportSubmit" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'Modifica Profilo - Visione' })
|
||||||
|
|
||||||
|
const { user, updateProfile, syncUser } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
first_name: user.value?.first_name || '',
|
||||||
|
last_name: user.value?.last_name || '',
|
||||||
|
phone: user.value?.phone || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const successMessage = ref('')
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
return formData.first_name.trim().length > 0 && formData.last_name.trim().length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await updateProfile({
|
||||||
|
first_name: formData.first_name,
|
||||||
|
last_name: formData.last_name,
|
||||||
|
phone: formData.phone || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok || res.user) {
|
||||||
|
await syncUser()
|
||||||
|
successMessage.value = 'Profilo aggiornato con successo.'
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/profile')
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage.value = error.data?.message || 'Errore durante l\'aggiornamento del profilo.'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-lg-8 col-xl-6">
|
||||||
|
<div class="card card-bg border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4 p-lg-5">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<NuxtLink to="/profile" class="btn btn-sm btn-link text-decoration-none ps-0 me-3">
|
||||||
|
<BIIcon name="it-arrow-left" size="sm" />
|
||||||
|
</NuxtLink>
|
||||||
|
<h1 class="h3 mb-0">Modifica Profilo</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Aggiorna i tuoi dati personali. Email e Codice Fiscale non sono modificabili.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="d-grid gap-4">
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="first_name" class="form-label">Nome</label>
|
||||||
|
<div class="input-field-control">
|
||||||
|
<BIIcon name="it-user" size="sm" class="input-field-icon" />
|
||||||
|
<input
|
||||||
|
id="first_name"
|
||||||
|
v-model="formData.first_name"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="last_name" class="form-label">Cognome</label>
|
||||||
|
<div class="input-field-control">
|
||||||
|
<BIIcon name="it-user" size="sm" class="input-field-icon" />
|
||||||
|
<input
|
||||||
|
id="last_name"
|
||||||
|
v-model="formData.last_name"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field">
|
||||||
|
<label for="phone" class="form-label">Telefono (opzionale)</label>
|
||||||
|
<div class="input-field-control">
|
||||||
|
<BIIcon name="it-telephone" size="sm" class="input-field-icon" />
|
||||||
|
<input
|
||||||
|
id="phone"
|
||||||
|
v-model="formData.phone"
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field opacity-50">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<div class="input-field-control">
|
||||||
|
<BIIcon name="it-mail" size="sm" class="input-field-icon" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control disabled"
|
||||||
|
:value="user?.email"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-field opacity-50">
|
||||||
|
<label class="form-label">Codice Fiscale</label>
|
||||||
|
<div class="input-field-control">
|
||||||
|
<BIIcon name="it-card" size="sm" class="input-field-icon" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control disabled"
|
||||||
|
:value="user?.fiscal_code"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger mb-0" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="successMessage" class="alert alert-success mb-0" role="alert">
|
||||||
|
{{ successMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-100 py-2 mt-2"
|
||||||
|
:disabled="!canSubmit || isLoading"
|
||||||
|
>
|
||||||
|
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
{{ isLoading ? 'Aggiornamento in corso...' : 'Salva modifiche' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.input-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field-control {
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #c5d0db;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
padding-left: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field-control:focus-within {
|
||||||
|
border-color: #06c;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field-icon {
|
||||||
|
color: #5c6f82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field-control .form-control {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export default defineNuxtPlugin(async () => {
|
||||||
|
const auth = useAuth()
|
||||||
|
const currentPath = useNuxtApp().$router.currentRoute.value.path
|
||||||
|
|
||||||
|
if ((auth.token.value || auth.tokenState.value) && !auth.user.value) {
|
||||||
|
try {
|
||||||
|
await auth.syncUser({ currentPath })
|
||||||
|
} catch (error) {
|
||||||
|
const status = (error as any)?.statusCode || (error as any)?.status || (error as any)?.response?.status
|
||||||
|
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
console.error('[Auth Plugin] Sync failed with authentication error, keeping session state:', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[Auth Plugin] Sync failed:', error)
|
||||||
|
auth.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export default defineNuxtPlugin(async (nuxtApp) => {
|
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||||
|
// @ts-expect-error
|
||||||
await import('bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js')
|
await import('bootstrap-italia/dist/js/bootstrap-italia.bundle.min.js')
|
||||||
})
|
})
|
||||||
Vendored
+37
@@ -208,6 +208,12 @@ export interface ChangePasswordRequest {
|
|||||||
new_password: string;
|
new_password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EmailRequest {
|
export interface EmailRequest {
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
@@ -261,11 +267,24 @@ export interface CreateAreaRequest {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateAreaRequest {
|
||||||
|
name?: string;
|
||||||
|
boundary_geojson?: GeoJsonObject;
|
||||||
|
description?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateCategoryRequest {
|
export interface CreateCategoryRequest {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateCategoryRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateTeamRequest {
|
export interface CreateTeamRequest {
|
||||||
team_category_id: number;
|
team_category_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -273,6 +292,14 @@ export interface CreateTeamRequest {
|
|||||||
area_id?: number;
|
area_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateTeamRequest {
|
||||||
|
team_category_id?: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
area_id?: number | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateUserBanRequest {
|
export interface UpdateUserBanRequest {
|
||||||
banned: boolean;
|
banned: boolean;
|
||||||
}
|
}
|
||||||
@@ -315,6 +342,7 @@ export interface PagedReportListResponse {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
page: number;
|
page: number;
|
||||||
per_page: number;
|
per_page: number;
|
||||||
|
total?: number;
|
||||||
reports: Report[];
|
reports: Report[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +367,12 @@ export interface InterventionSummaryResponse {
|
|||||||
latest_messages: Message[];
|
latest_messages: Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssignmentLinkResponse {
|
||||||
|
ok: boolean;
|
||||||
|
url: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MediaUploadResponse {
|
export interface MediaUploadResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
media: Media[];
|
media: Media[];
|
||||||
@@ -393,6 +427,7 @@ export interface ModerationListResponse {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
page: number;
|
page: number;
|
||||||
per_page: number;
|
per_page: number;
|
||||||
|
total?: number;
|
||||||
flags: ModerationFlag[];
|
flags: ModerationFlag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,4 +441,6 @@ export interface OverviewStatsResponse {
|
|||||||
monthly_reports: { month: string; total: number }[];
|
monthly_reports: { month: string; total: number }[];
|
||||||
avg_resolution_hours_by_category: { category: string; value: number }[];
|
avg_resolution_hours_by_category: { category: string; value: number }[];
|
||||||
avg_resolution_hours_by_area: { area: string; value: number }[];
|
avg_resolution_hours_by_area: { area: string; value: number }[];
|
||||||
|
total_users?: number;
|
||||||
|
open_reports?: number;
|
||||||
}
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
declare module '#app' {
|
||||||
|
interface PageMeta {
|
||||||
|
auth?: {
|
||||||
|
role?: 'CITIZEN' | 'OPERATOR' | 'ADMIN'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const BARI_BOUNDS = {
|
||||||
|
south: 41.0160584,
|
||||||
|
north: 41.1697837,
|
||||||
|
west: 16.7172376,
|
||||||
|
east: 17.0334109,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BARI_CENTER: [number, number] = [41.1257843, 16.8620293]
|
||||||
|
|
||||||
|
export const isWithinBariBounds = (latitude: number, longitude: number) =>
|
||||||
|
latitude >= BARI_BOUNDS.south &&
|
||||||
|
latitude <= BARI_BOUNDS.north &&
|
||||||
|
longitude >= BARI_BOUNDS.west &&
|
||||||
|
longitude <= BARI_BOUNDS.east
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
visione-frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
NUXT_SITE_URL: ${NUXT_PUBLIC_URL}
|
||||||
|
NUXT_PUBLIC_URL: ${NUXT_PUBLIC_URL}
|
||||||
|
NUXT_PUBLIC_API: ${NUXT_PUBLIC_API}
|
||||||
|
environment:
|
||||||
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
|
NUXT_SITE_URL: ${NUXT_PUBLIC_URL}
|
||||||
|
NUXT_PUBLIC_URL: ${NUXT_PUBLIC_URL}
|
||||||
|
NUXT_PUBLIC_API: ${NUXT_PUBLIC_API}
|
||||||
|
restart: always
|
||||||
+35
-3
@@ -17,13 +17,14 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
site: {
|
site: {
|
||||||
url: 'http://localhost:3000',
|
url: 'https://visione.studentindipendenti.it',
|
||||||
name: 'Visione - Un\'occhio per la sicurezza del cittadino.',
|
name: 'Visione - Un\'occhio per la sicurezza del cittadino.',
|
||||||
},
|
},
|
||||||
|
|
||||||
css: [
|
css: [
|
||||||
'@/assets/css/style.css',
|
'leaflet/dist/leaflet.css',
|
||||||
'bootstrap-italia/dist/css/bootstrap-italia.min.css'
|
'bootstrap-italia/dist/css/bootstrap-italia.min.css',
|
||||||
|
'@/assets/css/style.css'
|
||||||
],
|
],
|
||||||
|
|
||||||
fonts: {
|
fonts: {
|
||||||
@@ -32,5 +33,36 @@ export default defineNuxtConfig({
|
|||||||
{ name: 'Roboto Mono', provider: 'google' },
|
{ name: 'Roboto Mono', provider: 'google' },
|
||||||
{ name: 'Titillium Web', provider: 'google' }
|
{ name: 'Titillium Web', provider: 'google' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
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/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
link: [
|
||||||
|
{ rel: 'icon', type: 'image/svg+xml', href: '/icon.svg' }
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
+4
-1
@@ -15,11 +15,14 @@
|
|||||||
"@nuxtjs/robots": "6.0.9",
|
"@nuxtjs/robots": "6.0.9",
|
||||||
"@nuxtjs/sitemap": "8.0.15",
|
"@nuxtjs/sitemap": "8.0.15",
|
||||||
"bootstrap-italia": "^2.18.1",
|
"bootstrap-italia": "^2.18.1",
|
||||||
|
"codice-fiscale-js": "^2.3.23",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"nuxt": "^4.4.7",
|
"nuxt": "^4.4.7",
|
||||||
"vue": "^3.5.35",
|
"vue": "^3.5.35",
|
||||||
"vue-router": "^5.1.0"
|
"vue-router": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/fonts": "^0.14.0"
|
"@nuxt/fonts": "^0.14.0",
|
||||||
|
"@types/leaflet": "^1.9.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
import { createError, getQuery } from 'h3'
|
||||||
|
import { reverseGeocode } from '../../utils/geocoding'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const latitude = typeof query.lat === 'string' ? Number.parseFloat(query.lat) : Number.NaN
|
||||||
|
const longitude = typeof query.lng === 'string' ? Number.parseFloat(query.lng) : Number.NaN
|
||||||
|
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Invalid coordinates'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = await reverseGeocode(latitude, longitude)
|
||||||
|
|
||||||
|
return { address }
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createError, getQuery } from 'h3'
|
||||||
|
import { searchAddresses } from '../../utils/geocoding'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const rawText = typeof query.q === 'string' ? query.q.trim() : ''
|
||||||
|
const rawLimit = typeof query.limit === 'string' ? Number.parseInt(query.limit, 10) : 5
|
||||||
|
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 8) : 5
|
||||||
|
|
||||||
|
if (rawText.length < 3) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Query too short'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = await searchAddresses(rawText, limit)
|
||||||
|
|
||||||
|
return { suggestions }
|
||||||
|
})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
interface NominatimAddress {
|
||||||
|
road?: string
|
||||||
|
house_number?: string
|
||||||
|
pedestrian?: string
|
||||||
|
footway?: string
|
||||||
|
path?: string
|
||||||
|
cycleway?: string
|
||||||
|
suburb?: string
|
||||||
|
neighbourhood?: string
|
||||||
|
city?: string
|
||||||
|
town?: string
|
||||||
|
village?: string
|
||||||
|
municipality?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NominatimResult {
|
||||||
|
lat: string
|
||||||
|
lon: string
|
||||||
|
display_name: string
|
||||||
|
address?: NominatimAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeocodingSuggestion {
|
||||||
|
label: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOMINATIM_BASE_URL = 'https://nominatim.openstreetmap.org'
|
||||||
|
const BARI_BOUNDS = {
|
||||||
|
north: 41.1696,
|
||||||
|
south: 41.0718,
|
||||||
|
east: 16.9521,
|
||||||
|
west: 16.7564
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOMINATIM_HEADERS = {
|
||||||
|
'User-Agent': 'Visione Frontend/1.0 (https://visione.studentindipendenti.it)',
|
||||||
|
'Referer': 'https://visione.studentindipendenti.it/'
|
||||||
|
}
|
||||||
|
|
||||||
|
const bariViewbox = `${BARI_BOUNDS.west},${BARI_BOUNDS.north},${BARI_BOUNDS.east},${BARI_BOUNDS.south}`
|
||||||
|
|
||||||
|
const isWithinBariBounds = (latitude: number, longitude: number) =>
|
||||||
|
latitude >= BARI_BOUNDS.south &&
|
||||||
|
latitude <= BARI_BOUNDS.north &&
|
||||||
|
longitude >= BARI_BOUNDS.west &&
|
||||||
|
longitude <= BARI_BOUNDS.east
|
||||||
|
|
||||||
|
const compactAddress = (displayName: string, address?: NominatimAddress) => {
|
||||||
|
if (!address) {
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
const streetName = address.road || address.pedestrian || address.footway || address.path || address.cycleway
|
||||||
|
const street = [streetName, address.house_number].filter(Boolean).join(' ')
|
||||||
|
const locality = address.city || address.town || address.village || address.municipality || 'Bari'
|
||||||
|
const area = address.suburb || address.neighbourhood
|
||||||
|
const formatted = [street, locality, area]
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((part, index, all) => all.indexOf(part) === index)
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
return formatted || displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSuggestion = (result: NominatimResult): GeocodingSuggestion | null => {
|
||||||
|
const latitude = Number.parseFloat(result.lat)
|
||||||
|
const longitude = Number.parseFloat(result.lon)
|
||||||
|
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: compactAddress(result.display_name, result.address),
|
||||||
|
latitude,
|
||||||
|
longitude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchAddresses = async (query: string, limit = 5) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: query,
|
||||||
|
format: 'jsonv2',
|
||||||
|
addressdetails: '1',
|
||||||
|
'accept-language': 'it',
|
||||||
|
countrycodes: 'it',
|
||||||
|
bounded: '1',
|
||||||
|
viewbox: bariViewbox,
|
||||||
|
limit: String(limit)
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await $fetch<NominatimResult[]>(`${NOMINATIM_BASE_URL}/search?${params.toString()}`, {
|
||||||
|
headers: NOMINATIM_HEADERS
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
.map(toSuggestion)
|
||||||
|
.filter((result): result is GeocodingSuggestion => Boolean(result))
|
||||||
|
.filter((result) => isWithinBariBounds(result.latitude, result.longitude))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reverseGeocode = async (latitude: number, longitude: number) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
lat: String(latitude),
|
||||||
|
lon: String(longitude),
|
||||||
|
zoom: '18',
|
||||||
|
format: 'jsonv2',
|
||||||
|
addressdetails: '1',
|
||||||
|
'accept-language': 'it'
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await $fetch<NominatimResult | null>(`${NOMINATIM_BASE_URL}/reverse?${params.toString()}`, {
|
||||||
|
headers: NOMINATIM_HEADERS
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return compactAddress(result.display_name, result.address)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user