Compare commits
9
Commits
@@ -376,3 +376,11 @@ html body .navbar .navbar-collapsable .navbar-nav li.nav-item a.nav-link[data-fo
|
||||
max-width: 14rem;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-position-y: center;
|
||||
}
|
||||
|
||||
.card::after {
|
||||
margin-top: 0!important;
|
||||
}
|
||||
+119
-19
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BIIcon from "~/components/BIIcon.vue";
|
||||
|
||||
@@ -10,6 +10,22 @@ watch(() => route.path, () => {
|
||||
isMobileMenuOpen.value = false
|
||||
})
|
||||
|
||||
watch(isMobileMenuOpen, (isOpen) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
const navLinks = [
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Fai una segnalazione', path: '/reports/new' },
|
||||
@@ -28,8 +44,7 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="it-header-slim-wrapper-content">
|
||||
<a class="d-none d-lg-block navbar-brand"
|
||||
href="https://www.cittametropolitana.ba.it/">
|
||||
<a class="d-none d-lg-block navbar-brand" href="https://www.cittametropolitana.ba.it/">
|
||||
Città metropolitana di Bari
|
||||
</a>
|
||||
<div class="nav-mobile">
|
||||
@@ -48,7 +63,7 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
|
||||
<NuxtLink class="btn btn-primary btn-icon btn-full" to="/dashboard"
|
||||
data-element="personal-area-dashboard" data-focus-mouse="false">
|
||||
<span class="rounded-icon" aria-hidden="true">
|
||||
<BIIcon name="it-list" color="black" />
|
||||
<BIIcon name="it-list" color="black"/>
|
||||
</span>
|
||||
<span class="d-none d-lg-block">{{ panelLabel }}</span>
|
||||
</NuxtLink>
|
||||
@@ -56,7 +71,7 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
|
||||
<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" />
|
||||
<BIIcon name="it-user" color="black"/>
|
||||
</span>
|
||||
<span class="d-none d-lg-block">{{ user?.first_name }}</span>
|
||||
</NuxtLink>
|
||||
@@ -64,7 +79,7 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
|
||||
<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" />
|
||||
<BIIcon name="it-logout" color="black"/>
|
||||
</span>
|
||||
<span class="d-none d-lg-block">Esci</span>
|
||||
</button>
|
||||
@@ -73,12 +88,11 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
|
||||
<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" />
|
||||
<BIIcon name="it-user" color="black"/>
|
||||
</span>
|
||||
<span class="d-none d-lg-block">Login</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,7 +107,7 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
|
||||
<div class="it-header-center-content-wrapper">
|
||||
<div class="it-brand-wrapper">
|
||||
<NuxtLink to="/">
|
||||
<img src="/icon.svg" alt="Logo Visione" style="width: 48px; height: 48px; margin-right: 16px;" />
|
||||
<img src="/icon.svg" alt="Logo Visione" class="logo"/>
|
||||
<div class="it-brand-text">
|
||||
<div class="it-brand-title">Visione</div>
|
||||
<div class="it-brand-tagline d-none d-md-block">Un occhio per la sicurezza del cittadino.</div>
|
||||
@@ -111,6 +125,7 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<nav class="navbar navbar-expand-lg has-megamenu" aria-label="Navigazione principale">
|
||||
|
||||
<button
|
||||
class="custom-navbar-toggler"
|
||||
type="button"
|
||||
@@ -124,19 +139,16 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
|
||||
class="overlay"
|
||||
:class="{ 'show': isMobileMenuOpen }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
style="display: block;"
|
||||
v-show="isMobileMenuOpen"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="navbar-collapsable"
|
||||
:class="{ 'expanded': isMobileMenuOpen }"
|
||||
style="display: block;"
|
||||
>
|
||||
<div class="close-div">
|
||||
<button class="btn close-menu" type="button" @click="isMobileMenuOpen = false">
|
||||
<span class="visually-hidden">Nascondi la navigazione</span>
|
||||
<BIIcon name="it-close-big"/>
|
||||
<BIIcon name="it-close-big" color="black"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -184,14 +196,102 @@ const panelLabel = computed(() => user.value?.role?.toUpperCase() === 'ADMIN' ?
|
||||
background-color: rgb(0, 76.5, 153);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
transition: opacity 0.3s ease;
|
||||
@media (max-width: 991.98px) {
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
||||
z-index: 1040;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.overlay.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.navbar-collapsable {
|
||||
position: fixed !important;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 320px;
|
||||
max-width: 85vw;
|
||||
height: 100vh;
|
||||
background-color: #ffffff !important;
|
||||
z-index: 1050 !important;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 4px 0 15px rgba(0, 0, 0, 0.2);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.navbar-collapsable.expanded {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.close-div {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.close-menu {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.close-menu :deep(svg) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.menu-wrapper {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 1rem 1.5rem;
|
||||
color: #1a1a1a !important;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: #004c99 !important;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
.btn-full {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-inline: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -44,6 +44,7 @@ const removeFile = (idx: number) => {
|
||||
<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" />
|
||||
@@ -52,7 +53,7 @@ const removeFile = (idx: number) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer-shell">
|
||||
<div class="d-flex align-items-stretch gap-2">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
@@ -64,7 +65,7 @@ const removeFile = (idx: number) => {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary btn-icon composer-attach"
|
||||
class="btn btn-outline-primary btn-square flex-shrink-0"
|
||||
title="Allega file"
|
||||
@click="fileInputRef?.click()"
|
||||
:disabled="isSubmitting || files.length >= 5"
|
||||
@@ -74,21 +75,20 @@ const removeFile = (idx: number) => {
|
||||
|
||||
<textarea
|
||||
v-model="message"
|
||||
class="form-control composer-textarea"
|
||||
class="form-control chat-textarea m-0"
|
||||
placeholder="Scrivi aggiornamento o domanda"
|
||||
rows="2"
|
||||
:disabled="isSubmitting"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary composer-submit"
|
||||
class="btn btn-primary btn-square flex-shrink-0"
|
||||
:disabled="isSubmitting || (message.trim() === '' && files.length === 0)"
|
||||
title="Invia"
|
||||
>
|
||||
<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
|
||||
<span v-if="isSubmitting" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<BIIcon v-else name="it-arrow-right" size="sm" color="white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -126,54 +126,32 @@ const removeFile = (idx: number) => {
|
||||
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;
|
||||
}
|
||||
.chat-textarea {
|
||||
resize: none !important;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
overflow-y: auto;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.composer-textarea {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.chat-textarea:focus {
|
||||
border-color: #06c;
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
|
||||
}
|
||||
|
||||
.composer-submit {
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
}
|
||||
.btn-square {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
actionVariant?: ActionVariant
|
||||
actionLabel?: string
|
||||
showAssignee?: boolean
|
||||
processingIds?: number[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -16,6 +17,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
actionVariant: 'open',
|
||||
actionLabel: 'Open report',
|
||||
showAssignee: false,
|
||||
processingIds: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -53,7 +55,7 @@ const reportHref = (report: Report) => `${props.detailBasePath}/${report.id}`
|
||||
<td class="report-table-id">#{{ report.id }}</td>
|
||||
<td class="text-nowrap">{{ report.category_name || '-' }}</td>
|
||||
<td>
|
||||
<span class="report-address" :title="report.address">
|
||||
<span class="report-address d-inline-block text-truncate align-middle" style="max-width: 10ch;" :title="report.address">
|
||||
{{ report.address || '-' }}
|
||||
</span>
|
||||
</td>
|
||||
@@ -69,14 +71,16 @@ const reportHref = (report: Report) => `${props.detailBasePath}/${report.id}`
|
||||
v-if="actionVariant === 'manage'"
|
||||
type="button"
|
||||
class="btn btn-primary btn-xs"
|
||||
:disabled="processingIds.includes(report.id)"
|
||||
@click="emit('take', report.id)"
|
||||
>
|
||||
<span v-if="processingIds.includes(report.id)" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
Prendi in carico
|
||||
</button>
|
||||
<NuxtLink
|
||||
v-else
|
||||
:to="reportHref(report)"
|
||||
class="btn btn-outline-primary btn-xs btn-icon"
|
||||
class="btn btn-outline-primary btn-xs btn-icon d-inline-flex align-items-center justify-content-center"
|
||||
:aria-label="actionLabel"
|
||||
:title="actionLabel"
|
||||
>
|
||||
|
||||
@@ -4,11 +4,18 @@ 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 processingIds = ref<number[]>([])
|
||||
|
||||
const handleTakeReport = async (id: number) => {
|
||||
processingIds.value.push(id)
|
||||
try {
|
||||
const res = await takeReport(id)
|
||||
if (res.ok) {
|
||||
await Promise.all([refreshOpen(), refreshAssigned()])
|
||||
}
|
||||
} finally {
|
||||
processingIds.value = processingIds.value.filter(pid => pid !== id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ div.italian-flag {
|
||||
background: linear-gradient(to right, #009246 0%, #009246 33.33%, #F1F2F1 33.33%, #F1F2F1 66.66%, #CE2B37 66.66%, #CE2B37 100%);
|
||||
}
|
||||
|
||||
div.italian-flag {
|
||||
height: 8px;
|
||||
width: 100%;
|
||||
box-shadow: rgb(0 0 0 / 0.7) 0 0 30px;
|
||||
background: linear-gradient(to right, #009246 0%, #009246 33.33%, #F1F2F1 33.33%, #F1F2F1 66.66%, #CE2B37 66.66%, #CE2B37 100%);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -34,14 +33,12 @@ 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' }
|
||||
|
||||
@@ -33,6 +33,7 @@ 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',
|
||||
@@ -43,7 +44,39 @@ const statusMeta: Record<string, string> = {
|
||||
CLOSED: 'Chiusa'
|
||||
}
|
||||
|
||||
// Form Cambia Stato
|
||||
const statusTransitions: Record<string, string[]> = {
|
||||
OPEN: ['UNDER_REVIEW', 'REJECTED'],
|
||||
UNDER_REVIEW: ['ASSIGNED', 'REJECTED'],
|
||||
ASSIGNED: ['IN_PROGRESS', 'REJECTED'],
|
||||
IN_PROGRESS: ['RESOLVED', 'REJECTED'],
|
||||
RESOLVED: ['CLOSED'],
|
||||
REJECTED: ['CLOSED'],
|
||||
CLOSED: []
|
||||
}
|
||||
|
||||
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' }
|
||||
]
|
||||
|
||||
const selectableStatuses = computed(() => {
|
||||
if (!report.value?.status) return []
|
||||
const allowed = statusTransitions[report.value.status] || []
|
||||
return availableStatuses.filter(s => allowed.includes(s.value))
|
||||
})
|
||||
|
||||
const canCreateInterventionLink = computed(() => {
|
||||
if (summary.value?.assignments && Array.isArray(summary.value.assignments)) {
|
||||
return summary.value.assignments.some((a: any) => !a.ended_at)
|
||||
}
|
||||
return ['ASSIGNED', 'IN_PROGRESS'].includes(report.value?.status)
|
||||
})
|
||||
|
||||
const newStatus = ref<ReportStatus>('IN_PROGRESS')
|
||||
const statusNote = ref('')
|
||||
const isUpdatingStatus = ref(false)
|
||||
@@ -62,21 +95,19 @@ const isRevokeInterventionModalOpen = ref(false)
|
||||
|
||||
// Messaggi
|
||||
const isSubmittingMessage = ref(false)
|
||||
const isFlaggingMessageId = ref<number | null>(null)
|
||||
const flaggedMessageIds = ref<number[]>([])
|
||||
const isFlagMessageModalOpen = ref(false)
|
||||
const messageToFlag = ref<Message | null>(null)
|
||||
const isFlaggingMessage = ref(false)
|
||||
const isFlaggingMessageId = computed(() => isFlaggingMessage.value ? messageToFlag.value?.id : 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
|
||||
if (val) {
|
||||
const allowed = statusTransitions[val] || []
|
||||
if (allowed.length > 0) {
|
||||
newStatus.value = allowed[0] as ReportStatus
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const handleStatusUpdate = async () => {
|
||||
@@ -130,6 +161,7 @@ const copyInterventionLink = async () => {
|
||||
}
|
||||
|
||||
const handleCreateInterventionLink = async () => {
|
||||
if (!canCreateInterventionLink.value) return
|
||||
isGeneratingInterventionLink.value = true
|
||||
interventionLinkError.value = ''
|
||||
interventionLinkSuccess.value = ''
|
||||
@@ -197,21 +229,35 @@ const handleSendMessage = async (msgText: string, files: File[]) => {
|
||||
|
||||
const canFlagMessage = (message: Message) => {
|
||||
if (!user.value) return false
|
||||
return message.user_id !== user.value.id && message.user_id === report.value?.user_id
|
||||
const isCitizenMessage = message.user_id !== user.value.id && message.user_id === report.value?.user_id
|
||||
const isAlreadyFlagged = flaggedMessageIds.value.includes(message.id)
|
||||
return isCitizenMessage && !isAlreadyFlagged
|
||||
}
|
||||
|
||||
const handleFlagMessage = async (message: Message) => {
|
||||
const handleFlagMessage = (message: Message) => {
|
||||
if (!canFlagMessage(message)) return
|
||||
|
||||
isFlaggingMessageId.value = message.id
|
||||
messageToFlag.value = message
|
||||
isFlagMessageModalOpen.value = true
|
||||
}
|
||||
|
||||
const executeFlagMessage = async () => {
|
||||
if (!messageToFlag.value) return
|
||||
|
||||
isFlaggingMessage.value = true
|
||||
try {
|
||||
await flagMessage(reportId, message.id, {
|
||||
await flagMessage(reportId, messageToFlag.value.id, {
|
||||
reason: 'Messaggio segnalato da operatore responsabile per verifica moderazione',
|
||||
})
|
||||
|
||||
flaggedMessageIds.value.push(messageToFlag.value.id)
|
||||
|
||||
isFlagMessageModalOpen.value = false
|
||||
messageToFlag.value = null
|
||||
} catch (e) {
|
||||
console.error('Failed to flag message', e)
|
||||
} finally {
|
||||
isFlaggingMessageId.value = null
|
||||
isFlaggingMessage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,7 +439,7 @@ const formattedInterventionLinkExpiry = computed(() =>
|
||||
Scade: {{ formattedInterventionLinkExpiry || '-' }}
|
||||
</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-primary" :disabled="isGeneratingInterventionLink" @click="handleCreateInterventionLink">
|
||||
<button type="button" class="btn btn-outline-primary" :disabled="isGeneratingInterventionLink || !canCreateInterventionLink" @click="handleCreateInterventionLink">
|
||||
<span v-if="isGeneratingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Rigenera link
|
||||
</button>
|
||||
@@ -408,23 +454,29 @@ const formattedInterventionLinkExpiry = computed(() =>
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-outline-primary w-100"
|
||||
:disabled="isGeneratingInterventionLink"
|
||||
:disabled="isGeneratingInterventionLink || !canCreateInterventionLink"
|
||||
@click="handleCreateInterventionLink"
|
||||
>
|
||||
<span v-if="isGeneratingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Genera link
|
||||
</button>
|
||||
|
||||
<div v-if="!canCreateInterventionLink" class="text-danger small mt-2">
|
||||
È necessaria un'assegnazione in corso per generare o rigenerare questo link.
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<template v-if="selectableStatuses.length > 0">
|
||||
<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">
|
||||
<option v-for="st in selectableStatuses" :key="st.value" :value="st.value">
|
||||
{{ st.label }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -433,11 +485,18 @@ const formattedInterventionLinkExpiry = computed(() =>
|
||||
<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">
|
||||
<button type="submit" class="btn btn-primary w-100" :disabled="isUpdatingStatus">
|
||||
<span v-if="isUpdatingStatus" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Aggiorna Stato
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="alert alert-info mb-0 small">
|
||||
La segnalazione è attualmente in stato <strong>{{ statusMeta[report.status] }}</strong>. Non sono consentite ulteriori transizioni.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -510,6 +569,17 @@ const formattedInterventionLinkExpiry = computed(() =>
|
||||
@cancel="closeRevokeInterventionLinkModal"
|
||||
@confirm="handleRevokeInterventionLink"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
:open="isFlagMessageModalOpen"
|
||||
title="Segnala messaggio"
|
||||
message="Sei sicuro di voler segnalare questo messaggio al team di moderazione per una verifica?"
|
||||
confirm-label="Segnala"
|
||||
confirm-class="btn-warning"
|
||||
:pending="isFlaggingMessage"
|
||||
@cancel="isFlagMessageModalOpen = false; messageToFlag = null"
|
||||
@confirm="executeFlagMessage"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
|
||||
@@ -154,3 +154,9 @@ const formatDate = (dateString: string) =>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.card .card-body .d-grid > a {
|
||||
padding-block: 8px;
|
||||
}
|
||||
</style>
|
||||
+53
-43
@@ -31,7 +31,10 @@ const statusHistory = computed(() => (reportData.value as any)?.history || [])
|
||||
const messages = computed(() => (messagesData.value as any)?.messages || [])
|
||||
|
||||
const newMessage = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const isSubmittingMessage = ref(false)
|
||||
|
||||
const isFeedbackOpen = ref(false)
|
||||
const feedbackRating = ref(5)
|
||||
const feedbackComment = ref('')
|
||||
@@ -39,14 +42,27 @@ const isSubmittingFeedback = ref(false)
|
||||
const selectedMediaIndex = ref(0)
|
||||
const isGalleryOpen = ref(false)
|
||||
|
||||
// Gestione selezione file
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files) {
|
||||
selectedFiles.value = Array.from(target.files)
|
||||
}
|
||||
}
|
||||
|
||||
// Invia messaggio
|
||||
const handleSendMessage = async (msgText: string, files: File[]) => {
|
||||
if (!msgText.trim() && files.length === 0) return
|
||||
const handleSendMessage = async () => {
|
||||
if (!newMessage.value.trim() && selectedFiles.value.length === 0) return
|
||||
|
||||
isSubmittingMessage.value = true
|
||||
try {
|
||||
// Gestione mock di file, nella realtà si usa FormData o simle
|
||||
await addMessage(reportId, { body: msgText })
|
||||
await addMessage(reportId, { body: newMessage.value })
|
||||
|
||||
// Pulisci i campi
|
||||
newMessage.value = ''
|
||||
selectedFiles.value = []
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
|
||||
await refreshMessages()
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error)
|
||||
@@ -75,7 +91,10 @@ const handleSubmitFeedback = async () => {
|
||||
}
|
||||
|
||||
const isOwner = computed(() => report.value?.user_id === user.value?.id)
|
||||
const canFeedback = computed(() => report.value?.status === 'RESOLVED' || report.value?.status === 'CLOSED')
|
||||
|
||||
// Valutazione solo se lo stato è specificamente "RESOLVED"
|
||||
const canFeedback = computed(() => report.value?.status === 'RESOLVED')
|
||||
|
||||
const statusMeta: Record<string, { label: string; tone: string }> = {
|
||||
OPEN: { label: 'Aperta', tone: 'info' },
|
||||
UNDER_REVIEW: { label: 'In revisione', tone: 'warning' },
|
||||
@@ -180,30 +199,21 @@ const formatDate = (dateString: string): string => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="statusHistory.length > 0" class="card card-bg border-0 shadow-sm mb-4">
|
||||
<div 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">
|
||||
<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>
|
||||
<small v-if="item.note" class="text-muted d-block">{{ item.note }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="item.note" class="small mt-1 bg-light p-1 rounded">{{ item.note }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -219,8 +229,10 @@ const formatDate = (dateString: string): string => {
|
||||
</div>
|
||||
<span class="badge bg-light text-dark border">{{ messages.length }} messaggi</span>
|
||||
</div>
|
||||
|
||||
<MessageList :messages="messages" />
|
||||
<MessageComposer @send="handleSendMessage" />
|
||||
|
||||
<MessageComposer @send=handleSendMessage />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,23 +420,24 @@ const formatDate = (dateString: string): string => {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #006f9d;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #f0f0f0;
|
||||
.chat-textarea {
|
||||
resize: none !important;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
overflow-y: auto;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.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%);
|
||||
.btn-square {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ratio {
|
||||
@@ -457,6 +470,3 @@ const formatDate = (dateString: string): string => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user