feat(operator): add status transitions, flag message modal, and intervention link guards

This commit is contained in:
2026-06-15 23:12:40 +02:00 Unverified
parent fd69070e5c
commit ef7f33c578
+227 -148
View File
@@ -33,6 +33,7 @@ const messages = computed(() => (messagesData.value as any)?.messages || [])
const summary = computed(() => (summaryData.value as any)?.summary) const summary = computed(() => (summaryData.value as any)?.summary)
const summaryMedia = computed(() => (summaryData.value as any)?.media || []) const summaryMedia = computed(() => (summaryData.value as any)?.media || [])
const teams = computed(() => teamsData.value?.teams || []) const teams = computed(() => teamsData.value?.teams || [])
const statusMeta: Record<string, string> = { const statusMeta: Record<string, string> = {
OPEN: 'Aperta', OPEN: 'Aperta',
UNDER_REVIEW: 'In revisione', UNDER_REVIEW: 'In revisione',
@@ -43,6 +44,43 @@ const statusMeta: Record<string, string> = {
CLOSED: 'Chiusa' CLOSED: 'Chiusa'
} }
// Mappa delle transizioni (Equivalente al backend C++)
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' }
]
// Computed per ottenere solo gli stati di destinazione validi
const selectableStatuses = computed(() => {
if (!report.value?.status) return []
const allowed = statusTransitions[report.value.status] || []
return availableStatuses.filter(s => allowed.includes(s.value))
})
// Logica per consentire il link di intervento
// Corrisponde alla query: SELECT id FROM assignments WHERE report_id = ? AND ended_at IS NULL
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)
})
// Form Cambia Stato // Form Cambia Stato
const newStatus = ref<ReportStatus>('IN_PROGRESS') const newStatus = ref<ReportStatus>('IN_PROGRESS')
const statusNote = ref('') const statusNote = ref('')
@@ -62,21 +100,20 @@ const isRevokeInterventionModalOpen = ref(false)
// Messaggi // Messaggi
const isSubmittingMessage = ref(false) const isSubmittingMessage = ref(false)
const isFlaggingMessageId = ref<number | null>(null) const flaggedMessageIds = ref<number[]>([]) // Array locale per evitare lo spam di segnalazioni
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 }[] = [ // Inizializza lo stato nel form (seleziona automaticamente la prima transizione disponibile)
{ 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) => { 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 }) }, { immediate: true })
const handleStatusUpdate = async () => { const handleStatusUpdate = async () => {
@@ -130,6 +167,7 @@ const copyInterventionLink = async () => {
} }
const handleCreateInterventionLink = async () => { const handleCreateInterventionLink = async () => {
if (!canCreateInterventionLink.value) return
isGeneratingInterventionLink.value = true isGeneratingInterventionLink.value = true
interventionLinkError.value = '' interventionLinkError.value = ''
interventionLinkSuccess.value = '' interventionLinkSuccess.value = ''
@@ -197,21 +235,37 @@ const handleSendMessage = async (msgText: string, files: File[]) => {
const canFlagMessage = (message: Message) => { const canFlagMessage = (message: Message) => {
if (!user.value) return false 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
// Non permettere di segnalare se l'id del messaggio è già nell'array (anti-spam)
const isAlreadyFlagged = flaggedMessageIds.value.includes(message.id)
return isCitizenMessage && !isAlreadyFlagged
} }
const handleFlagMessage = async (message: Message) => { const handleFlagMessage = (message: Message) => {
if (!canFlagMessage(message)) return if (!canFlagMessage(message)) return
isFlaggingMessageId.value = message.id // Apri il modale per chiedere conferma
messageToFlag.value = message
isFlagMessageModalOpen.value = true
}
const executeFlagMessage = async () => {
if (!messageToFlag.value) return
isFlaggingMessage.value = true
try { try {
await flagMessage(reportId, message.id, { await flagMessage(reportId, messageToFlag.value.id, {
reason: 'Messaggio segnalato da operatore responsabile per verifica moderazione', reason: 'Messaggio segnalato da operatore responsabile per verifica moderazione',
}) })
// Aggiungi l'id all'array anti-spam locale così l'utente non può più segnalarlo in questa sessione
flaggedMessageIds.value.push(messageToFlag.value.id)
isFlagMessageModalOpen.value = false
messageToFlag.value = null
} catch (e) { } catch (e) {
console.error('Failed to flag message', e) console.error('Failed to flag message', e)
} finally { } finally {
isFlaggingMessageId.value = null isFlaggingMessage.value = false
} }
} }
@@ -228,12 +282,12 @@ const latestCitizenMessage = computed(() => {
if (!reportUserId) return null if (!reportUserId) return null
return [...messages.value] return [...messages.value]
.reverse() .reverse()
.find((message) => message.user_id === reportUserId) || null .find((message) => message.user_id === reportUserId) || null
}) })
const formattedInterventionLinkExpiry = computed(() => const formattedInterventionLinkExpiry = computed(() =>
interventionLinkExpiresAt.value ? formatDate(interventionLinkExpiresAt.value) : '' interventionLinkExpiresAt.value ? formatDate(interventionLinkExpiresAt.value) : ''
) )
</script> </script>
@@ -326,10 +380,10 @@ const formattedInterventionLinkExpiry = computed(() =>
<span class="badge bg-light text-dark border">{{ messages.length }} messaggi</span> <span class="badge bg-light text-dark border">{{ messages.length }} messaggi</span>
</div> </div>
<MessageList <MessageList
:messages="messages" :messages="messages"
:can-flag-message="canFlagMessage" :can-flag-message="canFlagMessage"
:is-flagging-message-id="isFlaggingMessageId" :is-flagging-message-id="isFlaggingMessageId"
@flag="handleFlagMessage" @flag="handleFlagMessage"
/> />
<MessageComposer @send="handleSendMessage" /> <MessageComposer @send="handleSendMessage" />
</div> </div>
@@ -338,126 +392,139 @@ const formattedInterventionLinkExpiry = computed(() =>
<div class="col-12 col-xl-5"> <div class="col-12 col-xl-5">
<div class="report-sidebar"> <div class="report-sidebar">
<div class="card card-bg border-0 shadow-sm mb-4" v-if="summary"> <div class="card card-bg border-0 shadow-sm mb-4" v-if="summary">
<div class="card-body"> <div class="card-body">
<h2 class="h6 mb-3">Quadro operativo</h2> <h2 class="h6 mb-3">Quadro operativo</h2>
<ul class="report-summary-list list-unstyled mb-4"> <ul class="report-summary-list list-unstyled mb-4">
<li> <li>
<span>Operatore</span> <span>Operatore</span>
<strong>{{ summary.assigned_operator_id ? `#${summary.assigned_operator_id}` : 'Nessuno' }}</strong> <strong>{{ summary.assigned_operator_id ? `#${summary.assigned_operator_id}` : 'Nessuno' }}</strong>
</li> </li>
<li> <li>
<span>Team disponibili</span> <span>Team disponibili</span>
<strong>{{ teams.length }}</strong> <strong>{{ teams.length }}</strong>
</li> </li>
<li> <li>
<span>Allegati</span> <span>Allegati</span>
<strong>{{ summaryMedia.length || media.length }}</strong> <strong>{{ summaryMedia.length || media.length }}</strong>
</li> </li>
</ul> </ul>
<div class="report-description"> <div class="report-description">
<h3 class="h6 mb-2">Ultimo messaggio cittadino</h3> <h3 class="h6 mb-2">Ultimo messaggio cittadino</h3>
<p class="small mb-0"> <p class="small mb-0">
{{ latestCitizenMessage?.body || 'Nessun messaggio del cittadino disponibile.' }} {{ latestCitizenMessage?.body || 'Nessun messaggio del cittadino disponibile.' }}
</p> </p>
</div>
</div> </div>
</div> <div class="card-body pt-0">
<div class="card-body pt-0"> <button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="teams.length === 0">
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="teams.length === 0"> Assegna team
Assegna team
</button>
</div>
</div>
<div class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h6 mb-3">Link intervento pubblico</h2>
<p class="text-muted small mb-3">Genera link temporaneo per team esterno. Nuova generazione ruota link esistente.</p>
<div v-if="interventionLinkError" class="alert alert-danger py-2" role="alert">
{{ interventionLinkError }}
</div>
<div v-if="interventionLinkSuccess" class="alert alert-success py-2" role="alert">
{{ interventionLinkSuccess }}
</div>
<template v-if="interventionLink">
<label class="form-label">URL attivo</label>
<div class="input-group mb-2">
<input type="text" class="form-control" :value="interventionLink" readonly />
<button type="button" class="btn btn-outline-primary" @click="copyInterventionLink">
Copia
</button>
</div>
<p class="small text-muted mb-3">
Scade: {{ formattedInterventionLinkExpiry || '-' }}
</p>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-primary" :disabled="isGeneratingInterventionLink" @click="handleCreateInterventionLink">
<span v-if="isGeneratingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
Rigenera link
</button>
<button type="button" class="btn btn-outline-danger" :disabled="isRevokingInterventionLink" @click="openRevokeInterventionLinkModal">
<span v-if="isRevokingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
Revoca link
</button>
</div>
</template>
<button
v-else
type="button"
class="btn btn-outline-primary w-100"
:disabled="isGeneratingInterventionLink"
@click="handleCreateInterventionLink"
>
<span v-if="isGeneratingInterventionLink" class="spinner-border spinner-border-sm me-2"></span>
Genera link
</button>
</div>
</div>
<div class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h6 mb-3">Aggiorna stato</h2>
<form @submit.prevent="handleStatusUpdate">
<div class="mb-3">
<label class="form-label">Nuovo Stato</label>
<select class="form-select" v-model="newStatus">
<option v-for="st in availableStatuses" :key="st.value" :value="st.value">
{{ st.label }}
</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Nota (visibile nella cronologia)</label>
<textarea class="form-control" v-model="statusNote" rows="2" placeholder="Motivazione o dettaglio..."></textarea>
</div>
<button type="submit" class="btn btn-primary w-100" :disabled="isUpdatingStatus || newStatus === report.status">
<span v-if="isUpdatingStatus" class="spinner-border spinner-border-sm me-2"></span>
Aggiorna Stato
</button> </button>
</form> </div>
</div> </div>
</div>
<div class="card card-bg border-0 shadow-sm"> <div class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
<h2 class="h6 mb-3">Cronologia eventi</h2> <h2 class="h6 mb-3">Link intervento pubblico</h2>
<div v-if="statusHistory.length === 0" class="small text-muted">Nessun evento registrato.</div> <p class="text-muted small mb-3">Genera link temporaneo per team esterno. Nuova generazione ruota link esistente.</p>
<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"> <div v-if="interventionLinkError" class="alert alert-danger py-2" role="alert">
<small class="text-muted d-block">{{ formatDate(item.created_at) }}</small> {{ interventionLinkError }}
<div class="d-flex align-items-center gap-2 mt-1"> </div>
<StatusBadge :status="item.old_status" /> <div v-if="interventionLinkSuccess" class="alert alert-success py-2" role="alert">
<BIIcon name="it-arrow-right" size="xs" /> {{ interventionLinkSuccess }}
<StatusBadge :status="item.new_status" /> </div>
<template v-if="interventionLink">
<label class="form-label">URL attivo</label>
<div class="input-group mb-2">
<input type="text" class="form-control" :value="interventionLink" readonly />
<button type="button" class="btn btn-outline-primary" @click="copyInterventionLink">
Copia
</button>
</div> </div>
<div v-if="item.note" class="small mt-1 bg-light p-1 rounded">{{ item.note }}</div> <p class="small text-muted mb-3">
</li> Scade: {{ formattedInterventionLinkExpiry || '-' }}
</ul> </p>
<div class="d-grid gap-2">
<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 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 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>
</div> </div>
</div> </div>
@@ -501,14 +568,26 @@ const formattedInterventionLinkExpiry = computed(() =>
</div> </div>
<ConfirmModal <ConfirmModal
:open="isRevokeInterventionModalOpen" :open="isRevokeInterventionModalOpen"
title="Revoca link intervento" title="Revoca link intervento"
message="Vuoi revocare link pubblico per team esterno? Link non sara piu utilizzabile." message="Vuoi revocare link pubblico per team esterno? Link non sara piu utilizzabile."
confirm-label="Revoca link" confirm-label="Revoca link"
confirm-class="btn-danger" confirm-class="btn-danger"
:pending="isRevokingInterventionLink" :pending="isRevokingInterventionLink"
@cancel="closeRevokeInterventionLinkModal" @cancel="closeRevokeInterventionLinkModal"
@confirm="handleRevokeInterventionLink" @confirm="handleRevokeInterventionLink"
/>
<!-- Nuovo Modale per Conferma Segnalazione -->
<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>
<div v-else class="text-center py-5"> <div v-else class="text-center py-5">
@@ -629,4 +708,4 @@ const formattedInterventionLinkExpiry = computed(() =>
position: static; position: static;
} }
} }
</style> </style>