9 Commits
10 changed files with 537 additions and 350 deletions
+8
View File
@@ -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
View File
@@ -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>
+30 -52
View File
@@ -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>
+6 -2
View File
@@ -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>
+7
View File
@@ -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
View File
@@ -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' }
+93 -23
View File
@@ -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>
+6
View File
@@ -154,3 +154,9 @@ const formatDate = (dateString: string) =>
</div>
</div>
</template>
<style>
.card .card-body .d-grid > a {
padding-block: 8px;
}
</style>
+53 -43
View File
@@ -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>