488 lines
15 KiB
Vue
488 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
|
|
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('/')
|
|
}
|
|
|
|
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 isSubmittingMessage = ref(false)
|
|
|
|
const isFeedbackOpen = ref(false)
|
|
const feedbackRating = ref(5)
|
|
const feedbackComment = ref('')
|
|
const isSubmittingFeedback = ref(false)
|
|
const submittedFeedback = ref<{ rating: number; comment?: string } | null>(null)
|
|
|
|
// Carica l'eventuale feedback da local storage dopo il mount (per evitare mismatch nel SSR)
|
|
onMounted(() => {
|
|
try {
|
|
const saved = localStorage.getItem(`feedback_${reportId}`)
|
|
if (saved) {
|
|
submittedFeedback.value = JSON.parse(saved)
|
|
}
|
|
} catch (error) {
|
|
console.error('Errore nel recupero del feedback da local storage:', error)
|
|
}
|
|
})
|
|
|
|
const handleSendMessage = async (messageText: string, files: File[]) => {
|
|
isSubmittingMessage.value = true
|
|
try {
|
|
await addMessage(reportId, { body: messageText })
|
|
|
|
await refreshMessages()
|
|
} catch (error) {
|
|
console.error('Error sending message:', error)
|
|
} finally {
|
|
isSubmittingMessage.value = false
|
|
}
|
|
}
|
|
|
|
const handleSubmitFeedback = async () => {
|
|
isSubmittingFeedback.value = true
|
|
try {
|
|
const feedbackData = {
|
|
rating: feedbackRating.value,
|
|
comment: feedbackComment.value || undefined,
|
|
}
|
|
|
|
await addFeedback(reportId, feedbackData)
|
|
|
|
// Salva in local storage in mancanza di un endpoint dal backend
|
|
localStorage.setItem(`feedback_${reportId}`, JSON.stringify(feedbackData))
|
|
submittedFeedback.value = feedbackData
|
|
|
|
isFeedbackOpen.value = false
|
|
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')
|
|
|
|
const canChat = computed(() => {
|
|
if (!report.value) return false
|
|
const closedStatuses = ['RESOLVED', 'CLOSED', 'REJECTED']
|
|
return !closedStatuses.includes(report.value.status)
|
|
})
|
|
|
|
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: 'Conclusa', 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 class="me-2" color="primary" name="it-map-marker-circle" size="sm"/>
|
|
<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 class="me-2" color="primary" name="it-camera" size="sm"/>
|
|
<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 :alt="`Allegato ${Number(idx) + 1}`" :src="runtimeConfig.public.api + m.url"
|
|
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 color="white" name="it-video" size="xl"/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card card-bg border-0 shadow-sm mb-4">
|
|
<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 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 class="me-2" color="primary" name="it-comment" size="sm"/>
|
|
<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 v-if="canChat" @send="handleSendMessage"/>
|
|
<div v-else class="alert alert-secondary mt-3 mb-0 text-center small">
|
|
La chat è disabilitata poiché la segnalazione è in stato
|
|
<strong>{{ statusMeta[report.status]?.label || report.status }}</strong>.
|
|
</div>
|
|
|
|
</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>
|
|
|
|
<template v-if="submittedFeedback">
|
|
<div class="alert alert-success small mb-3">
|
|
Grazie! Il tuo feedback è stato inviato con successo.
|
|
</div>
|
|
<div class="mb-3">
|
|
<span class="d-block text-muted small mb-1">La tua valutazione</span>
|
|
<div class="d-flex gap-1">
|
|
<BIIcon
|
|
v-for="n in 5"
|
|
:key="n"
|
|
:name="n <= submittedFeedback.rating ? 'it-star-full' : 'it-star-outline'"
|
|
size="sm"
|
|
:color="n <= submittedFeedback.rating ? 'warning' : 'primary'"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div v-if="submittedFeedback.comment">
|
|
<span class="d-block text-muted small mb-1">Il tuo commento</span>
|
|
<p class="mb-0 small text-break">{{ submittedFeedback.comment }}</p>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<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 :disabled="isSubmittingFeedback" class="btn btn-primary" type="button"
|
|
@click="handleSubmitFeedback">
|
|
<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>
|
|
</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;
|
|
}
|
|
|
|
.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>
|