948 lines
34 KiB
Vue
948 lines
34 KiB
Vue
<script setup lang="ts">
|
|
import type { FlagRequest, Message, Report, 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, flagReport, getTeamSuggestions } = useReports()
|
|
const {
|
|
takeReport,
|
|
getInterventionSummary,
|
|
updateReportStatus,
|
|
assignTeam,
|
|
getTeams,
|
|
createInterventionLink,
|
|
revokeInterventionLink
|
|
} = useOperator()
|
|
const { user } = useAuth()
|
|
|
|
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: allTeamsData } = await useAsyncData('operator-teams', () => getTeams())
|
|
const { data: suggestedTeamsData } = await useAsyncData(`op-team-suggestions-${reportId}`, () => getTeamSuggestions(reportId))
|
|
|
|
const report = computed<Report | undefined>(() => (reportData.value as any)?.report)
|
|
const media = computed(() => (reportData.value as any)?.media || [])
|
|
const statusHistory = computed(() => (reportData.value as any)?.history || [])
|
|
const messages = computed<Message[]>(() => (messagesData.value as any)?.messages || [])
|
|
const summary = computed(() => (summaryData.value as any)?.summary)
|
|
const summaryMedia = computed(() => (summaryData.value as any)?.media || [])
|
|
|
|
const allTeams = computed(() => (allTeamsData.value as any)?.teams || [])
|
|
const suggestedTeams = computed(() => (suggestedTeamsData.value as any)?.suggestions || [])
|
|
|
|
const otherTeams = computed(() => {
|
|
const suggestedIds = new Set(suggestedTeams.value.map((t: any) => Number(t.id)))
|
|
return allTeams.value.filter((t: any) => !suggestedIds.has(Number(t.id)))
|
|
})
|
|
|
|
const currentUserId = computed(() => user.value?.id ?? null)
|
|
const isTakenByCurrentOperator = computed(() => report.value?.assigned_operator_id === currentUserId.value)
|
|
const isOpenAndUnassigned = computed(() => report.value?.status === 'OPEN' && !report.value?.assigned_operator_id)
|
|
const isReadOnlyPreview = computed(() => !isTakenByCurrentOperator.value)
|
|
const canTakeReport = computed(() => isOpenAndUnassigned.value && !isTakenByCurrentOperator.value)
|
|
const isFinalStatus = computed(() => ['REJECTED', 'RESOLVED', 'CLOSED'].includes(report.value?.status || ''))
|
|
const canAssignTeam = computed(() => !isReadOnlyPreview.value && !isFinalStatus.value && allTeams.value.length > 0)
|
|
const canSendMessage = computed(() => !isReadOnlyPreview.value && !isFinalStatus.value)
|
|
const canChangeStatus = computed(() => !isReadOnlyPreview.value && !isFinalStatus.value && selectableStatuses.value.length > 0)
|
|
|
|
const statusMeta: Record<string, string> = {
|
|
OPEN: 'Aperta',
|
|
UNDER_REVIEW: 'In revisione',
|
|
ASSIGNED: 'Assegnata',
|
|
IN_PROGRESS: 'In lavorazione',
|
|
RESOLVED: 'Risolta',
|
|
REJECTED: 'Respinta',
|
|
CLOSED: 'Conclusa'
|
|
}
|
|
|
|
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: 'Conclusa' }
|
|
]
|
|
|
|
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 (isReadOnlyPreview.value) return false
|
|
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)
|
|
const isTakingReport = ref(false)
|
|
|
|
const isAssignModalOpen = ref(false)
|
|
const selectedTeamId = ref<number | null>(null)
|
|
const isAssigning = ref(false)
|
|
const interventionLink = ref('')
|
|
const interventionLinkExpiresAt = ref('')
|
|
const interventionPublicLink = ref('')
|
|
const isGeneratingInterventionLink = ref(false)
|
|
const isRevokingInterventionLink = ref(false)
|
|
const interventionLinkError = ref('')
|
|
const interventionLinkSuccess = ref('')
|
|
const isRevokeInterventionModalOpen = ref(false)
|
|
const copyFeedback = ref('')
|
|
const actionError = ref('')
|
|
const actionSuccess = ref('')
|
|
|
|
const isSubmittingMessage = ref(false)
|
|
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 isFlagReportModalOpen = ref(false)
|
|
const isFlaggingReport = ref(false)
|
|
const reportFlagError = ref('')
|
|
const reportFlagSuccess = ref('')
|
|
const reportFlagForm = reactive<FlagRequest>({
|
|
reason: '',
|
|
details: ''
|
|
})
|
|
|
|
// === GESTIONE SCROLL SCHERMO CON MODALI ===
|
|
watch(
|
|
() => isAssignModalOpen.value || isFlagReportModalOpen.value || isRevokeInterventionModalOpen.value || isFlagMessageModalOpen.value,
|
|
(isOpen) => {
|
|
if (import.meta.client) {
|
|
if (isOpen) {
|
|
document.body.style.overflow = 'hidden'
|
|
} else {
|
|
document.body.style.overflow = ''
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
onUnmounted(() => {
|
|
if (import.meta.client) {
|
|
document.body.style.overflow = ''
|
|
}
|
|
})
|
|
|
|
watch(() => report.value?.status, (val) => {
|
|
if (val) {
|
|
const allowed = statusTransitions[val] || []
|
|
if (allowed.length > 0) {
|
|
newStatus.value = allowed[0] as ReportStatus
|
|
}
|
|
}
|
|
}, { immediate: true })
|
|
|
|
const resetReportFlagForm = () => {
|
|
reportFlagForm.reason = ''
|
|
reportFlagForm.details = ''
|
|
reportFlagError.value = ''
|
|
}
|
|
|
|
const clearActionFeedback = () => {
|
|
actionError.value = ''
|
|
actionSuccess.value = ''
|
|
}
|
|
|
|
const setPublicInterventionLink = (url?: string) => {
|
|
if (!url) {
|
|
interventionPublicLink.value = ''
|
|
return
|
|
}
|
|
|
|
try {
|
|
const rawUrl = new URL(url, runtimeConfig.public.api)
|
|
interventionPublicLink.value = `${runtimeConfig.public.url}${rawUrl.pathname.replace(/^\/api\/v1/, '')}`
|
|
} catch {
|
|
interventionPublicLink.value = url
|
|
}
|
|
}
|
|
|
|
const handleTakeReport = async () => {
|
|
if (!canTakeReport.value || !report.value) return
|
|
|
|
isTakingReport.value = true
|
|
clearActionFeedback()
|
|
try {
|
|
const res = await takeReport(report.value.id)
|
|
if (res.ok) {
|
|
await Promise.all([refreshReport(), refreshSummary()])
|
|
actionSuccess.value = 'Segnalazione presa in carico.'
|
|
}
|
|
} catch (e: any) {
|
|
console.error('Failed to take report', e)
|
|
actionError.value = e?.data?.message || 'Presa in carico non riuscita.'
|
|
} finally {
|
|
isTakingReport.value = false
|
|
}
|
|
}
|
|
|
|
const handleStatusUpdate = async () => {
|
|
if (!canChangeStatus.value) return
|
|
isUpdatingStatus.value = true
|
|
clearActionFeedback()
|
|
try {
|
|
const res = await updateReportStatus(reportId, {
|
|
status: newStatus.value,
|
|
note: statusNote.value || undefined
|
|
})
|
|
if (res.ok) {
|
|
statusNote.value = ''
|
|
await Promise.all([refreshReport(), refreshSummary()])
|
|
actionSuccess.value = 'Stato aggiornato con successo.'
|
|
}
|
|
} catch (e: any) {
|
|
console.error('Failed to update status', e)
|
|
actionError.value = e?.data?.message || 'Aggiornamento stato non riuscito.'
|
|
} finally {
|
|
isUpdatingStatus.value = false
|
|
}
|
|
}
|
|
|
|
const handleAssignTeam = async () => {
|
|
if (!canAssignTeam.value) return
|
|
if (!selectedTeamId.value) return
|
|
|
|
isAssigning.value = true
|
|
clearActionFeedback()
|
|
try {
|
|
const res = await assignTeam(reportId, {
|
|
team_id: selectedTeamId.value
|
|
})
|
|
if (res.ok) {
|
|
isAssignModalOpen.value = false
|
|
selectedTeamId.value = null
|
|
await Promise.all([refreshReport(), refreshSummary()])
|
|
actionSuccess.value = 'Team assegnato con successo.'
|
|
}
|
|
} catch (e: any) {
|
|
console.error('Failed to assign team', e)
|
|
actionError.value = e?.data?.message || 'Assegnazione team non riuscita.'
|
|
} finally {
|
|
isAssigning.value = false
|
|
}
|
|
}
|
|
|
|
const copyInterventionLink = async () => {
|
|
if (!interventionPublicLink.value || !navigator?.clipboard) return
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(interventionPublicLink.value)
|
|
copyFeedback.value = 'Copiato'
|
|
interventionLinkSuccess.value = 'Link copiato negli appunti.'
|
|
interventionLinkError.value = ''
|
|
setTimeout(() => {
|
|
copyFeedback.value = ''
|
|
}, 2000)
|
|
} catch {
|
|
interventionLinkError.value = 'Copia automatica non riuscita.'
|
|
interventionLinkSuccess.value = ''
|
|
copyFeedback.value = ''
|
|
}
|
|
}
|
|
|
|
const handleCreateInterventionLink = async () => {
|
|
if (!canCreateInterventionLink.value) return
|
|
isGeneratingInterventionLink.value = true
|
|
clearActionFeedback()
|
|
interventionLinkError.value = ''
|
|
interventionLinkSuccess.value = ''
|
|
|
|
try {
|
|
const res = await createInterventionLink(reportId)
|
|
if (res.ok) {
|
|
interventionLink.value = res.url
|
|
interventionLinkExpiresAt.value = res.expires_at
|
|
setPublicInterventionLink(res.url)
|
|
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
|
|
clearActionFeedback()
|
|
interventionLinkError.value = ''
|
|
interventionLinkSuccess.value = ''
|
|
|
|
try {
|
|
const res = await revokeInterventionLink(reportId)
|
|
if (res.ok) {
|
|
interventionLink.value = ''
|
|
interventionLinkExpiresAt.value = ''
|
|
interventionPublicLink.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 (!canSendMessage.value) return
|
|
if (!msgText.trim() && files.length === 0) return
|
|
isSubmittingMessage.value = true
|
|
clearActionFeedback()
|
|
try {
|
|
await addMessage(reportId, { body: msgText })
|
|
await refreshMessages()
|
|
actionSuccess.value = 'Messaggio inviato.'
|
|
} catch (e: any) {
|
|
console.error('Failed to send message', e)
|
|
actionError.value = e?.data?.message || 'Invio messaggio non riuscito.'
|
|
} finally {
|
|
isSubmittingMessage.value = false
|
|
}
|
|
}
|
|
|
|
const canFlagMessage = (message: Message) => {
|
|
if (!user.value) return false
|
|
|
|
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 = (message: Message) => {
|
|
if (!canFlagMessage(message)) return
|
|
|
|
messageToFlag.value = message
|
|
isFlagMessageModalOpen.value = true
|
|
}
|
|
|
|
const executeFlagMessage = async () => {
|
|
if (!messageToFlag.value) return
|
|
|
|
isFlaggingMessage.value = true
|
|
try {
|
|
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
|
|
actionSuccess.value = 'Messaggio segnalato alla moderazione.'
|
|
} catch (e: any) {
|
|
console.error('Failed to flag message', e)
|
|
actionError.value = e?.data?.message || 'Segnalazione messaggio non riuscita.'
|
|
} finally {
|
|
isFlaggingMessage.value = false
|
|
}
|
|
}
|
|
|
|
const executeFlagReport = async () => {
|
|
if (!reportFlagForm.reason.trim()) {
|
|
reportFlagError.value = 'Inserisci un motivo.'
|
|
return
|
|
}
|
|
|
|
isFlaggingReport.value = true
|
|
reportFlagError.value = ''
|
|
reportFlagSuccess.value = ''
|
|
|
|
try {
|
|
await flagReport(reportId, {
|
|
reason: reportFlagForm.reason.trim(),
|
|
details: reportFlagForm.details.trim() || undefined
|
|
})
|
|
|
|
reportFlagSuccess.value = 'Segnalazione inviata alla moderazione.'
|
|
isFlagReportModalOpen.value = false
|
|
resetReportFlagForm()
|
|
} catch (e: any) {
|
|
console.error('Failed to flag report', e)
|
|
reportFlagError.value = e?.data?.message || 'Invio segnalazione non riuscito.'
|
|
} finally {
|
|
isFlaggingReport.value = false
|
|
}
|
|
}
|
|
|
|
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="isTakenByCurrentOperator ? '/operator/assigned' : '/operator'" class="btn btn-sm btn-link text-decoration-none ps-0 mb-2">
|
|
← Torna all'elenco
|
|
</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">Segnalazione #{{ reportId }}</span>
|
|
<StatusBadge :status="report.status" />
|
|
<span v-if="isReadOnlyPreview" class="badge bg-secondary">Sola lettura</span>
|
|
</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 v-if="isReadOnlyPreview" class="alert alert-info mt-3 mb-0">
|
|
Stai visualizzando segnalazione in sola lettura. Prendila in carico per aggiornare stato, assegnare team o scrivere al cittadino.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container my-5">
|
|
<div v-if="actionError" class="alert alert-danger mb-4">
|
|
{{ actionError }}
|
|
</div>
|
|
<div v-if="actionSuccess" class="alert alert-success mb-4">
|
|
{{ actionSuccess }}
|
|
</div>
|
|
|
|
<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 class="mt-4 d-flex flex-wrap gap-2">
|
|
<button type="button" class="btn btn-outline-warning btn-sm" @click="isFlagReportModalOpen = true">
|
|
Segnala alla moderazione
|
|
</button>
|
|
<button v-if="canTakeReport" type="button" class="btn btn-primary btn-sm" :disabled="isTakingReport" @click="handleTakeReport">
|
|
<span v-if="isTakingReport" class="spinner-border spinner-border-sm me-2"></span>
|
|
Prendi in carico
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="reportFlagSuccess" class="alert alert-success mt-3 mb-0">
|
|
{{ reportFlagSuccess }}
|
|
</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"
|
|
/>
|
|
<div v-if="isReadOnlyPreview" class="alert alert-secondary mt-3 mb-0 small">
|
|
{{ isFinalStatus ? 'Messaggistica non disponibile per segnalazioni concluse o rifiutate.' : 'Messaggistica disponibile dopo la presa in carico.' }}
|
|
</div>
|
|
<MessageComposer v-else-if="canSendMessage" @send="handleSendMessage" />
|
|
<div v-else class="alert alert-secondary mt-3 mb-0 small">
|
|
Messaggistica non disponibile per segnalazioni concluse o rifiutate.
|
|
</div>
|
|
</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 suggeriti</span>
|
|
<strong :class="{'text-primary': suggestedTeams.length > 0}">{{ suggestedTeams.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" v-if="!isReadOnlyPreview">
|
|
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="!canAssignTeam">
|
|
Assegna team
|
|
</button>
|
|
<div v-if="isFinalStatus" class="small text-muted mt-2">
|
|
Assegnazione team non disponibile per segnalazioni concluse o rifiutate.
|
|
</div>
|
|
</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="interventionPublicLink">
|
|
<label class="form-label">URL attivo</label>
|
|
<div class="input-group mb-2">
|
|
<input type="text" class="form-control" :value="interventionPublicLink" readonly />
|
|
<button type="button" class="btn btn-outline-primary" @click="copyInterventionLink">
|
|
{{ copyFeedback || 'Copia' }}
|
|
</button>
|
|
</div>
|
|
<p class="small text-muted mb-3">
|
|
Scade: {{ formattedInterventionLinkExpiry || '-' }}
|
|
</p>
|
|
<div class="d-grid gap-2" v-if="!isReadOnlyPreview">
|
|
<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>
|
|
<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 || !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 una presa in carico e un'assegnazione in corso per generare 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="isReadOnlyPreview">
|
|
<div class="alert alert-secondary mb-0 small">
|
|
Aggiornamento stato disponibile dopo la presa in carico.
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="canChangeStatus">
|
|
<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 selectableStatuses" :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">
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<!-- BACKGROUND MODALE COMUNE -->
|
|
<div v-if="isAssignModalOpen || isFlagReportModalOpen" class="modal-backdrop fade show d-block"
|
|
style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
|
|
|
|
<!-- MODALE: GESTIONE INTERVENTO -->
|
|
<div v-if="isAssignModalOpen" class="modal fade show d-block" style="z-index: 1050; overflow-y: auto;"
|
|
tabindex="-1">
|
|
<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>
|
|
<!-- FORM AGGIORNATO CON MODAL-BODY E MODAL-FOOTER -->
|
|
<form @submit.prevent="handleAssignTeam">
|
|
<div class="modal-body">
|
|
<div class="mb-0">
|
|
<label class="form-label">Team incaricato</label>
|
|
<select v-model.number="selectedTeamId" class="form-select" required>
|
|
<option :value="null">Seleziona team</option>
|
|
|
|
<optgroup v-if="suggestedTeams.length > 0" label="Team Suggeriti">
|
|
<option v-for="team in suggestedTeams" :key="'sug-' + team.id" :value="Number(team.id)">
|
|
{{ team.name }}
|
|
<template v-if="team.is_recommended"> ★</template>
|
|
<template v-if="team.category_name"> · {{ team.category_name }}</template>
|
|
<template v-if="team.area_name"> · {{ team.area_name }}</template>
|
|
</option>
|
|
</optgroup>
|
|
|
|
<optgroup :label="suggestedTeams.length > 0 ? 'Altri Team' : 'Tutti i Team'">
|
|
<option v-for="team in otherTeams" :key="'oth-' + team.id" :value="Number(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>
|
|
</optgroup>
|
|
</select>
|
|
<small v-if="allTeams.length === 0" class="form-text text-danger mt-1">
|
|
Nessun team attivo disponibile.
|
|
</small>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-outline-secondary" type="button" @click="isAssignModalOpen = false">Annulla
|
|
</button>
|
|
<button :disabled="isAssigning || !selectedTeamId" class="btn btn-primary" type="submit">
|
|
<span v-if="isAssigning" class="spinner-border spinner-border-sm me-2"></span>
|
|
Salva Assegnazione
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MODALE: SEGNALA ALLA MODERAZIONE -->
|
|
<div v-if="isFlagReportModalOpen" class="modal fade show d-block" style="z-index: 1050; overflow-y: auto;"
|
|
tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Segnala alla moderazione</h5>
|
|
<button type="button" class="btn-close" :disabled="isFlaggingReport" @click="isFlagReportModalOpen = false; resetReportFlagForm()"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div v-if="reportFlagError" class="alert alert-danger py-2">{{ reportFlagError }}</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Motivo</label>
|
|
<input v-model="reportFlagForm.reason" type="text" class="form-control" maxlength="200" />
|
|
</div>
|
|
<div class="mb-0">
|
|
<label class="form-label">Dettagli</label>
|
|
<textarea v-model="reportFlagForm.details" class="form-control" rows="4" placeholder="Dettagli aggiuntivi opzionali"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" :disabled="isFlaggingReport" @click="isFlagReportModalOpen = false; resetReportFlagForm()">Annulla</button>
|
|
<button type="button" class="btn btn-warning" :disabled="isFlaggingReport || !reportFlagForm.reason.trim()" @click="executeFlagReport">
|
|
<span v-if="isFlaggingReport" class="spinner-border spinner-border-sm me-2"></span>
|
|
Invia a moderazione
|
|
</button>
|
|
</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"
|
|
/>
|
|
|
|
<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>
|
|
</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>
|