feat: implement citizen report creation, detail view, and chat system
- Create report creation page with step-by-step media uploads and categories loading - Create report details view page with history events log and messaging section - Implement MessageComposer and MessageList components for chat workflow - Implement MediaUploader with drag-and-drop support, file type, and size validation - Implement Pagination component with dynamic buttons and ellipsis support - Implement user profile settings summary page and notifications listing page
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({ title: 'Dettaglio Segnalazione - Visione' })
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
|
||||
const route = useRoute()
|
||||
const { getReportDetail, getMessages, addMessage, addFeedback, flagReport } = useReports()
|
||||
const { user } = useAuth()
|
||||
|
||||
const reportId = Number(route.params.id)
|
||||
if (!reportId) {
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
// Fetch dati
|
||||
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 newMessage = ref('')
|
||||
const isSubmittingMessage = ref(false)
|
||||
const isFeedbackOpen = ref(false)
|
||||
const feedbackRating = ref(5)
|
||||
const feedbackComment = ref('')
|
||||
const isSubmittingFeedback = ref(false)
|
||||
const flagMessage = ref('')
|
||||
const isFlagging = ref(false)
|
||||
const selectedMediaIndex = ref(0)
|
||||
const isGalleryOpen = ref(false)
|
||||
|
||||
// Status badge color
|
||||
const getStatusColor = (status: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
OPEN: 'primary',
|
||||
UNDER_REVIEW: 'info',
|
||||
ASSIGNED: 'warning',
|
||||
IN_PROGRESS: 'warning',
|
||||
RESOLVED: 'success',
|
||||
REJECTED: 'danger',
|
||||
CLOSED: 'secondary',
|
||||
}
|
||||
return colors[status] || 'secondary'
|
||||
}
|
||||
|
||||
// Status label italiano
|
||||
const getStatusLabel = (status: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
OPEN: 'Aperta',
|
||||
UNDER_REVIEW: 'In revisione',
|
||||
ASSIGNED: 'Assegnata',
|
||||
IN_PROGRESS: 'In corso',
|
||||
RESOLVED: 'Risolta',
|
||||
REJECTED: 'Rifiutata',
|
||||
CLOSED: 'Chiusa',
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
// Invia messaggio
|
||||
const handleSendMessage = async () => {
|
||||
if (!newMessage.value.trim()) return
|
||||
|
||||
isSubmittingMessage.value = true
|
||||
try {
|
||||
await addMessage(reportId, { body: newMessage.value })
|
||||
newMessage.value = ''
|
||||
await refreshMessages()
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error)
|
||||
} finally {
|
||||
isSubmittingMessage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Invia feedback (solo se risolta)
|
||||
const handleSubmitFeedback = async () => {
|
||||
isSubmittingFeedback.value = true
|
||||
try {
|
||||
await addFeedback(reportId, {
|
||||
rating: feedbackRating.value,
|
||||
comment: feedbackComment.value || undefined,
|
||||
})
|
||||
isFeedbackOpen.value = false
|
||||
feedbackRating.value = 5
|
||||
feedbackComment.value = ''
|
||||
await refreshReport()
|
||||
} catch (error) {
|
||||
console.error('Error submitting feedback:', error)
|
||||
} finally {
|
||||
isSubmittingFeedback.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Segnala abuso
|
||||
const handleFlag = async () => {
|
||||
if (!flagMessage.value.trim()) return
|
||||
|
||||
isFlagging.value = true
|
||||
try {
|
||||
await flagReport(reportId, {
|
||||
reason: flagMessage.value,
|
||||
})
|
||||
flagMessage.value = ''
|
||||
// Mostra messaggio di successo
|
||||
} catch (error) {
|
||||
console.error('Error flagging report:', error)
|
||||
} finally {
|
||||
isFlagging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isOwner = computed(() => report.value?.user_id === user.value?.id)
|
||||
const canFeedback = computed(() => report.value?.status === 'RESOLVED' || report.value?.status === 'CLOSED')
|
||||
|
||||
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">
|
||||
<!-- HEADER -->
|
||||
<div class="bg-light border-bottom py-4">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8">
|
||||
<h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="badge" :class="`bg-${getStatusColor(report.status)}`">
|
||||
{{ getStatusLabel(report.status) }}
|
||||
</span>
|
||||
<small class="text-muted">
|
||||
<BIIcon name="it-clock" size="xs" class="me-1" />
|
||||
Creata il {{ formatDate(report.created_at) }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTENUTO PRINCIPALE -->
|
||||
<div class="container my-5">
|
||||
<div class="row">
|
||||
|
||||
<!-- SINISTRA: Dettagli segnalazione -->
|
||||
<div class="col-12 col-lg-8 mb-4">
|
||||
|
||||
<!-- INDIRIZZO E COORDINATE -->
|
||||
<div class="card card-bg mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title font-weight-bold mb-3">
|
||||
<BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
|
||||
Posizione
|
||||
</h6>
|
||||
<div class="mb-2">
|
||||
<strong>{{ report.address }}</strong>
|
||||
</div>
|
||||
<small class="text-muted d-block">
|
||||
Coordinate: {{ report.latitude }}, {{ report.longitude }}
|
||||
</small>
|
||||
<small v-if="report.area_name" class="text-muted d-block">
|
||||
Area: {{ report.area_name }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESCRIZIONE -->
|
||||
<div class="card card-bg mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title font-weight-bold mb-3">
|
||||
<BIIcon name="it-write" size="sm" color="primary" class="me-2" />
|
||||
Descrizione
|
||||
</h6>
|
||||
<p class="card-text text-break">{{ report.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GALLERY MEDIA -->
|
||||
<div v-if="media.length > 0" class="card card-bg mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title font-weight-bold mb-3">
|
||||
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
|
||||
Allegati ({{ media.length }})
|
||||
</h6>
|
||||
<div class="row">
|
||||
<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">
|
||||
<img :src="runtimeConfig.public.api + m.url" :alt="`Allegato ${idx + 1}`" class="object-fit-cover" />
|
||||
</div>
|
||||
<div v-else class="ratio ratio-1x1 bg-dark rounded d-flex align-items-center justify-content-center">
|
||||
<BIIcon name="it-video" size="xl" color="white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CRONOLOGIA STATI -->
|
||||
<div v-if="statusHistory.length > 0" class="card card-bg mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title font-weight-bold mb-4">
|
||||
<BIIcon name="it-history" size="sm" color="primary" class="me-2" />
|
||||
Cronologia
|
||||
</h6>
|
||||
<div class="timeline">
|
||||
<div v-for="item in statusHistory" :key="item.id" class="timeline-item mb-3">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="timeline-marker"></div>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<small class="text-muted">{{ formatDate(item.created_at) }}</small>
|
||||
<div class="small font-weight-semibold">
|
||||
{{ getStatusLabel(item.old_status) }} → {{ getStatusLabel(item.new_status) }}
|
||||
</div>
|
||||
<small v-if="item.note" class="text-muted d-block">{{ item.note }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MESSAGGI -->
|
||||
<div class="card card-bg">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title font-weight-bold mb-3">
|
||||
<BIIcon name="it-comment" size="sm" color="primary" class="me-2" />
|
||||
Chat ({{ messages.length }})
|
||||
</h6>
|
||||
<MessageList :messages="messages" />
|
||||
<MessageComposer @send="(msg) => { newMessage = msg; handleSendMessage() }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESTRA: Sidebar azioni -->
|
||||
<div class="col-12 col-lg-4">
|
||||
|
||||
<!-- FEEDBACK (se risolta) -->
|
||||
<div v-if="canFeedback && isOwner" class="card card-bg card-highlight mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title font-weight-bold mb-3">
|
||||
<BIIcon name="it-star-outline" size="sm" color="primary" class="me-2" />
|
||||
Lascia un feedback
|
||||
</h6>
|
||||
<button
|
||||
v-if="!isFeedbackOpen"
|
||||
type="button"
|
||||
class="btn btn-primary w-100"
|
||||
@click="isFeedbackOpen = true"
|
||||
>
|
||||
Valuta l'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' : '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 type="button" class="btn btn-primary" @click="handleSubmitFeedback" :disabled="isSubmittingFeedback">
|
||||
<span v-if="isSubmittingFeedback" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Invia
|
||||
</button>
|
||||
<button type="button" class="btn btn-light" @click="isFeedbackOpen = false">
|
||||
Cancella
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SEGNALA ABUSO -->
|
||||
<div class="card card-bg">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title font-weight-bold mb-3">
|
||||
<BIIcon name="it-flag" size="sm" color="danger" class="me-2" />
|
||||
Segnala abuso
|
||||
</h6>
|
||||
<textarea
|
||||
v-model="flagMessage"
|
||||
class="form-control form-control-sm mb-2"
|
||||
placeholder="Specifica il motivo dell'abuso..."
|
||||
rows="2"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-danger w-100"
|
||||
@click="handleFlag"
|
||||
:disabled="isFlagging || !flagMessage.trim()"
|
||||
>
|
||||
<BIIcon name="it-flag" size="xs" class="me-1" />
|
||||
Invia segnalazione
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- LOADING STATE -->
|
||||
<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>
|
||||
.card-highlight {
|
||||
border-color: #006f9d;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #006f9d;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.timeline-item:not(:last-child) .timeline-marker::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 20px;
|
||||
width: 2px;
|
||||
height: calc(100% + 20px);
|
||||
background-color: #006f9d;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user