feat: implement operator dashboard views and report management

This commit is contained in:
2026-06-13 10:28:55 +02:00 Unverified
parent c42a68c1ed
commit 3d6802f2bf
4 changed files with 556 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
<script setup lang="ts">
import type { 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 } = useReports()
const { getInterventionSummary, updateReportStatus, assignTeam } = useOperator()
// Dati principali
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 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 summary = computed(() => (summaryData.value as any)?.summary)
// Form Cambia Stato
const newStatus = ref<ReportStatus>('IN_PROGRESS')
const statusNote = ref('')
const isUpdatingStatus = ref(false)
// Form Assegna Squadra
const isAssignModalOpen = ref(false)
const teamName = ref('')
const assignNote = ref('')
const estimatedDate = ref('')
const isAssigning = ref(false)
// Messaggi
const isSubmittingMessage = ref(false)
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
}, { immediate: true })
const handleStatusUpdate = async () => {
isUpdatingStatus.value = true
try {
const res = await updateReportStatus(reportId, {
status: newStatus.value,
note: statusNote.value || undefined
})
if (res.ok) {
statusNote.value = ''
await Promise.all([refreshReport(), refreshSummary()])
}
} catch (e) {
console.error('Failed to update status', e)
} finally {
isUpdatingStatus.value = false
}
}
const handleAssignTeam = async () => {
if (!teamName.value) return
isAssigning.value = true
try {
const res = await assignTeam(reportId, {
assigned_to_team: teamName.value,
estimated_resolution_date: estimatedDate.value || undefined,
internal_notes: assignNote.value || undefined
})
if (res.ok) {
isAssignModalOpen.value = false
teamName.value = ''
assignNote.value = ''
estimatedDate.value = ''
await Promise.all([refreshReport(), refreshSummary()])
}
} catch (e) {
console.error('Failed to assign team', e)
} finally {
isAssigning.value = false
}
}
const handleSendMessage = async (msgText: string, files: File[]) => {
if (!msgText.trim() && files.length === 0) return
isSubmittingMessage.value = true
try {
await addMessage(reportId, { body: msgText })
await refreshMessages()
} catch (e) {
console.error('Failed to send message', e)
} finally {
isSubmittingMessage.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'
})
}
</script>
<template>
<div v-if="report">
<div class="bg-light border-bottom py-3">
<div class="container d-flex justify-content-between align-items-center">
<div>
<NuxtLink to="/operator/assigned" class="btn btn-sm btn-link text-decoration-none ps-0">
&larr; Torna alle assegnazioni
</NuxtLink>
<h1 class="h4 font-weight-bold mb-1">Intervento #{{ reportId }}</h1>
<div class="d-flex align-items-center gap-2">
<StatusBadge :status="report.status" />
<small class="text-muted">{{ report.category_name }}</small>
</div>
</div>
</div>
</div>
<div class="container my-5">
<div class="row">
<!-- Sinistra: Dettagli -->
<div class="col-12 col-lg-7 mb-4">
<div class="card card-bg mb-4">
<div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Dettagli Cittadino</h5>
<p class="mb-1"><strong>Indirizzo:</strong> {{ report.address }}</p>
<p class="mb-3"><strong>Posizione:</strong> {{ report.latitude }}, {{ report.longitude }}</p>
<h6 class="font-weight-bold">Descrizione</h6>
<p class="text-break bg-white p-3 border rounded">{{ report.description }}</p>
<div v-if="media.length > 0">
<h6 class="font-weight-bold mt-4 mb-3">Allegati del cittadino</h6>
<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" style="aspect-ratio: 1;" />
</div>
</div>
</div>
</div>
</div>
<div class="card card-bg mb-4">
<div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Comunicazioni con il Cittadino</h5>
<MessageList :messages="messages" />
<MessageComposer @send="handleSendMessage" />
</div>
</div>
</div>
<!-- Destra: Strumenti Operatore -->
<div class="col-12 col-lg-5">
<!-- Summary Box -->
<div class="card border-primary mb-4 shadow-sm" v-if="summary">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">Riepilogo Intervento</h5>
</div>
<div class="card-body">
<p class="mb-2"><strong>Operatore Assegnato:</strong> {{ summary.operator_id ? `#${summary.operator_id}` : 'Nessuno' }}</p>
<p class="mb-2"><strong>Squadra Assegnata:</strong> <span class="badge bg-secondary">{{ summary.assigned_to_team || 'Nessuna' }}</span></p>
<p class="mb-2"><strong>Data Prevista:</strong> {{ summary.estimated_resolution_date ? formatDate(summary.estimated_resolution_date) : 'Non impostata' }}</p>
<p class="mb-0"><strong>Note Interne:</strong></p>
<p class="small text-muted bg-light p-2 rounded border mt-1 mb-0">{{ summary.internal_notes || 'Nessuna nota interna' }}</p>
</div>
<div class="card-footer bg-white border-top-0 pt-0">
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true">
Gestisci Squadra e Note
</button>
</div>
</div>
<!-- Modifica Stato -->
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Aggiorna Stato</h5>
<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>
</form>
</div>
</div>
<!-- Cronologia -->
<div class="card card-bg">
<div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Cronologia Eventi</h5>
<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>
<!-- Modale Assegnazione -->
<div v-if="isAssignModalOpen" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
<div v-if="isAssignModalOpen" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
<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>
<div class="modal-body">
<form @submit.prevent="handleAssignTeam">
<div class="mb-3">
<label class="form-label">Squadra / Ditta Incaricata</label>
<input type="text" class="form-control" v-model="teamName" placeholder="Es. Squadra Verde SPA" required />
</div>
<div class="mb-3">
<label class="form-label">Data Prevista Risoluzione (opzionale)</label>
<input type="date" class="form-control" v-model="estimatedDate" />
</div>
<div class="mb-3">
<label class="form-label">Note Interne (private)</label>
<textarea class="form-control" v-model="assignNote" rows="3" placeholder="Note ad uso interno, non visibili al cittadino..."></textarea>
</div>
<div class="d-flex justify-content-end gap-2 mt-4">
<button type="button" class="btn btn-outline-secondary" @click="isAssignModalOpen = false">Annulla</button>
<button type="submit" class="btn btn-primary" :disabled="isAssigning || !teamName.trim()">
<span v-if="isAssigning" class="spinner-border spinner-border-sm me-2"></span>
Salva Assegnazione
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</template>
<style scoped>
.object-fit-cover {
object-fit: cover;
}
</style>