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
+226 -147
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,6 +44,43 @@ const statusMeta: Record<string, string> = {
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
const newStatus = ref<ReportStatus>('IN_PROGRESS')
const statusNote = ref('')
@@ -62,21 +100,20 @@ const isRevokeInterventionModalOpen = ref(false)
// Messaggi
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 }[] = [
{ 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
// Inizializza lo stato nel form (seleziona automaticamente la prima transizione disponibile)
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 +167,7 @@ const copyInterventionLink = async () => {
}
const handleCreateInterventionLink = async () => {
if (!canCreateInterventionLink.value) return
isGeneratingInterventionLink.value = true
interventionLinkError.value = ''
interventionLinkSuccess.value = ''
@@ -197,21 +235,37 @@ 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
// 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
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 {
await flagMessage(reportId, message.id, {
await flagMessage(reportId, messageToFlag.value.id, {
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) {
console.error('Failed to flag message', e)
} finally {
isFlaggingMessageId.value = null
isFlaggingMessage.value = false
}
}
@@ -228,12 +282,12 @@ const latestCitizenMessage = computed(() => {
if (!reportUserId) return null
return [...messages.value]
.reverse()
.find((message) => message.user_id === reportUserId) || null
.reverse()
.find((message) => message.user_id === reportUserId) || null
})
const formattedInterventionLinkExpiry = computed(() =>
interventionLinkExpiresAt.value ? formatDate(interventionLinkExpiresAt.value) : ''
interventionLinkExpiresAt.value ? formatDate(interventionLinkExpiresAt.value) : ''
)
</script>
@@ -326,10 +380,10 @@ const formattedInterventionLinkExpiry = computed(() =>
<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"
:messages="messages"
:can-flag-message="canFlagMessage"
:is-flagging-message-id="isFlaggingMessageId"
@flag="handleFlagMessage"
/>
<MessageComposer @send="handleSendMessage" />
</div>
@@ -338,126 +392,139 @@ const formattedInterventionLinkExpiry = computed(() =>
<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 disponibili</span>
<strong>{{ teams.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 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 disponibili</span>
<strong>{{ teams.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>
<div class="card-body pt-0">
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="teams.length === 0">
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
<div class="card-body pt-0">
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true" :disabled="teams.length === 0">
Assegna team
</button>
</form>
</div>
</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 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>
<div v-if="item.note" class="small mt-1 bg-light p-1 rounded">{{ item.note }}</div>
</li>
</ul>
<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 || !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>
@@ -501,14 +568,26 @@ const formattedInterventionLinkExpiry = computed(() =>
</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"
: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"
/>
<!-- 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 v-else class="text-center py-5">