feat(auth): integrate codice-fiscale-js for fiscal code validation in registration

This commit is contained in:
2026-06-14 19:54:18 +02:00 Unverified
parent 762a42196c
commit 038a399e93
7 changed files with 895 additions and 386 deletions
+103 -36
View File
@@ -42,28 +42,17 @@ const removeFile = (idx: number) => {
</script>
<template>
<div class="message-composer mt-4 border-top pt-3">
<div class="message-composer mt-3">
<form @submit.prevent="handleSubmit">
<!-- Preview Allegati -->
<div v-if="files.length > 0" class="mb-3 d-flex flex-wrap gap-2">
<div v-for="(file, idx) in files" :key="idx" class="badge bg-light text-dark border p-2 d-flex align-items-center gap-2">
<div v-if="files.length > 0" class="message-files mb-3">
<div v-for="(file, idx) in files" :key="idx" class="message-file-chip">
<BIIcon name="it-clip" size="xs" />
<span class="text-truncate" style="max-width: 150px;">{{ file.name }}</span>
<button type="button" class="btn-close ms-2" style="font-size: 0.5rem;" @click="removeFile(idx)" aria-label="Rimuovi allegato"></button>
<span class="text-truncate file-name">{{ file.name }}</span>
<button type="button" class="btn-close btn-close-sm" @click="removeFile(idx)" aria-label="Rimuovi allegato"></button>
</div>
</div>
<div class="input-group align-items-end">
<button
type="button"
class="btn btn-outline-secondary me-2 rounded"
title="Allega file (max 5)"
@click="fileInputRef?.click()"
:disabled="isSubmitting || files.length >= 5"
>
<BIIcon name="it-clip" size="sm" />
</button>
<div class="composer-shell">
<input
ref="fileInputRef"
type="file"
@@ -73,26 +62,38 @@ const removeFile = (idx: number) => {
accept="image/*,video/*,.pdf,.doc,.docx"
/>
<textarea
v-model="message"
class="form-control rounded"
placeholder="Scrivi un messaggio... (Ctrl+Enter per inviare)"
rows="2"
:disabled="isSubmitting"
@keydown="handleKeydown"
/>
<button
type="submit"
class="btn btn-primary ms-2 rounded"
:disabled="isSubmitting || (message.trim() === '' && files.length === 0)"
type="button"
class="btn btn-outline-secondary btn-icon composer-attach"
title="Allega file"
@click="fileInputRef?.click()"
:disabled="isSubmitting || files.length >= 5"
>
<BIIcon name="it-clip" size="sm" />
</button>
<textarea
v-model="message"
class="form-control composer-textarea"
placeholder="Scrivi aggiornamento o domanda"
rows="2"
:disabled="isSubmitting"
@keydown="handleKeydown"
/>
<button
type="submit"
class="btn btn-primary composer-submit"
:disabled="isSubmitting || (message.trim() === '' && files.length === 0)"
>
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<BIIcon v-else name="it-arrow-right" size="sm" color="white" class="me-1" />
Invia
</button>
</div>
<small class="text-muted d-block mt-2">
💡 <strong>Tip:</strong> Premi Ctrl+Enter per inviare velocemente
`Ctrl` + `Invio` per inviare. Fino a 5 allegati.
</small>
</form>
</div>
@@ -100,13 +101,79 @@ const removeFile = (idx: number) => {
<style scoped>
.message-composer {
background-color: #f9f9f9;
border-radius: 0.5rem;
padding: 1rem;
border-top: 1px solid #d9e3ee;
padding-top: 1rem;
}
textarea {
resize: vertical;
font-family: inherit;
.message-files {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>
.message-file-chip {
align-items: center;
background: #f2f7fc;
border: 1px solid #d9e3ee;
border-radius: 999px;
display: inline-flex;
gap: 0.5rem;
max-width: 100%;
padding: 0.4rem 0.75rem;
}
.file-name {
max-width: 14rem;
}
.composer-shell {
align-items: flex-end;
background: #fff;
border: 1px solid #c5d0db;
border-radius: 8px;
display: grid;
gap: 0.75rem;
grid-template-columns: auto minmax(0, 1fr) auto;
padding: 0.75rem;
}
.composer-shell:focus-within {
border-color: #06c;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
}
.composer-attach {
flex-shrink: 0;
}
.composer-textarea {
border: 0;
box-shadow: none;
min-height: 3rem;
padding: 0;
resize: vertical;
}
.composer-submit {
min-width: 7rem;
}
.btn-close-sm {
font-size: 0.55rem;
}
@media (max-width: 767.98px) {
.composer-shell {
grid-template-columns: auto 1fr;
}
.composer-textarea {
grid-column: 1 / -1;
}
.composer-submit {
grid-column: 2;
justify-self: end;
}
}
</style>
+106 -30
View File
@@ -28,6 +28,9 @@ const formatDate = (dateString: string): string => {
const auth = useAuth()
const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userId
const senderInitials = (msg: Message): string =>
`${msg.first_name?.[0] || ''}${msg.last_name?.[0] || ''}`.toUpperCase() || '?'
</script>
<template>
@@ -38,38 +41,46 @@ const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userI
</div>
</div>
<div v-else-if="messages.length === 0" class="alert alert-info text-center" role="alert">
Nessun messaggio disponibile. Sii il primo a scrivere!
<div v-else-if="messages.length === 0" class="message-empty-state" role="alert">
<BIIcon name="it-comment" size="lg" color="primary" class="mb-2" />
<strong class="d-block mb-1">Nessun messaggio</strong>
<span>Usa chat per aggiornamenti rapidi tra cittadino e operatori.</span>
</div>
<div v-else class="messages-container">
<div
v-for="msg in messages"
:key="msg.id"
class="message-bubble mb-3 d-flex"
:class="{ 'justify-content-end': isCurrentUser(msg.user_id), 'justify-content-start': !isCurrentUser(msg.user_id) }"
<div
v-for="msg in messages"
:key="msg.id"
class="message-row"
:class="{ 'is-current-user': isCurrentUser(msg.user_id) }"
>
<div v-if="!isCurrentUser(msg.user_id)" class="message-avatar" aria-hidden="true">
{{ senderInitials(msg) }}
</div>
<div
class="message-bubble-content p-3 rounded-3"
class="message-bubble-content"
:class="{
'bg-primary text-white': isCurrentUser(msg.user_id),
'bg-light border': !isCurrentUser(msg.user_id)
'message-bubble-outgoing': isCurrentUser(msg.user_id),
'message-bubble-incoming': !isCurrentUser(msg.user_id)
}"
>
<!-- Nome mittente (solo per messaggi non miei) -->
<div v-if="!isCurrentUser(msg.user_id)" class="small font-weight-bold mb-2 text-primary">
<div class="message-meta">
<span v-if="!isCurrentUser(msg.user_id)" class="message-author">
{{ msg.first_name }} {{ msg.last_name }}
</span>
<span v-else class="message-author">Tu</span>
<span class="message-time">
{{ formatDate(msg.created_at) }}
</span>
</div>
<div v-if="!isCurrentUser(msg.user_id)" class="small font-weight-bold mb-2 text-primary d-none">
{{ msg.first_name }} {{ msg.last_name }}
</div>
<!-- Corpo messaggio -->
<div class="message-body" :class="{ 'text-break': true }">
<div class="message-body">
{{ msg.body }}
</div>
<!-- Timestamp -->
<div class="small mt-2" :class="{ 'text-white-50': isCurrentUser(msg.user_id), 'text-muted': !isCurrentUser(msg.user_id) }">
{{ formatDate(msg.created_at) }}
</div>
</div>
</div>
</div>
@@ -78,34 +89,99 @@ const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userI
<style scoped>
.message-list {
max-height: 500px;
background: linear-gradient(180deg, #f7fafc 0%, #ffffff 100%);
border: 1px solid #d9e3ee;
border-radius: 8px;
max-height: 34rem;
overflow-y: auto;
padding: 1rem;
background-color: #f9f9f9;
border-radius: 0.5rem;
border: 1px solid #e9ecef;
}
.messages-container {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.message-bubble {
.message-empty-state {
align-items: center;
color: #5c6f82;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 14rem;
padding: 2rem 1rem;
text-align: center;
}
.message-row {
align-items: flex-end;
display: flex;
gap: 0.75rem;
justify-content: flex-start;
}
.message-row.is-current-user {
justify-content: flex-end;
}
.message-avatar {
align-items: center;
background: #dceeff;
border-radius: 999px;
color: #17324d;
display: inline-flex;
flex: 0 0 2.25rem;
font-size: 0.75rem;
font-weight: 700;
height: 2.25rem;
justify-content: center;
}
.message-bubble-content {
max-width: 75%;
word-wrap: break-word;
border-radius: 8px;
max-width: min(100%, 40rem);
padding: 0.875rem 1rem;
word-break: break-word;
}
.message-bubble-incoming {
background: #fff;
border: 1px solid #d9e3ee;
color: #17324d;
}
.message-bubble-outgoing {
background: #06c;
color: #fff;
}
.message-meta {
align-items: center;
display: flex;
font-size: 0.8125rem;
gap: 0.75rem;
justify-content: space-between;
margin-bottom: 0.375rem;
}
.message-author {
font-weight: 700;
}
.message-time {
opacity: 0.78;
white-space: nowrap;
}
.message-body {
white-space: pre-wrap;
line-height: 1.4;
white-space: pre-wrap;
}
@media (max-width: 767.98px) {
.message-bubble-content {
max-width: calc(100% - 3rem);
}
}
</style>
+183 -109
View File
@@ -54,131 +54,205 @@ const handleSubmit = async () => {
</script>
<template>
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div v-if="user?.must_change_password" class="callout callout-highlight callout-warning mb-4">
<div class="callout-title d-flex align-items-center">
<BIIcon name="it-warning-circle" size="sm" color="warning" class="me-2" />
Azione Richiesta
</div>
<p>Per motivi di sicurezza, imposta una nuova password per il tuo account.</p>
</div>
<div class="card card-bg shadow-sm">
<div class="card-body p-4">
<h3 class="card-title h4 mb-4">Cambia la tua password</h3>
<form @submit.prevent="handleSubmit">
<div class="form-group mb-3">
<div class="input-group">
<div class="input-group-prepend">
<div class="input-group-text"><BIIcon name="it-lock" size="sm" /></div>
<div class="password-reset-page py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-12 col-xl-10">
<div class="row g-4 align-items-stretch">
<div class="col-12 col-lg-5">
<section class="password-reset-intro h-100">
<span class="password-reset-eyebrow">Accesso protetto</span>
<h1 class="h2 mb-3">Imposta nuova password</h1>
<p class="mb-4">
Aggiorna credenziali prima di continuare. Password nuova deve essere diversa da quella attuale.
</p>
<div v-if="user?.must_change_password" class="callout callout-highlight callout-warning mb-0">
<div class="callout-title d-flex align-items-center">
<BIIcon name="it-warning-circle" size="sm" color="warning" class="me-2" />
Azione richiesta
</div>
<input
:type="showCurrent ? 'text' : 'password'"
class="form-control"
id="current_password"
v-model="formData.current_password"
>
<!-- FIX: Aggiunta classe 'active' dinamica per evitare sovrapposizione placeholder -->
<label for="current_password" :class="{ 'active': formData.current_password }">Password attuale</label>
<div class="input-group-append">
<button type="button" class="btn btn-primary btn-sm" @click="showCurrent = !showCurrent">
<BIIcon :name="showCurrent ? 'it-password-visible' : 'it-password-invisible'" size="sm" color="white" />
<p class="mb-0">Per motivi di sicurezza, imposta una nuova password per il tuo account.</p>
</div>
</section>
</div>
<div class="col-12 col-lg-7">
<div class="card card-bg shadow-sm border-0 h-100">
<div class="card-body p-4 p-lg-5">
<h2 class="card-title h4 mb-4">Cambia password</h2>
<form @submit.prevent="handleSubmit" class="d-grid gap-4">
<div class="password-field">
<label for="current_password" class="form-label">Password attuale</label>
<div class="password-field-control">
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
<input
id="current_password"
v-model="formData.current_password"
:type="showCurrent ? 'text' : 'password'"
class="form-control"
autocomplete="current-password"
>
<button
type="button"
class="btn btn-password-toggle"
:aria-label="showCurrent ? 'Nascondi password attuale' : 'Mostra password attuale'"
@click="showCurrent = !showCurrent"
>
<BIIcon :name="showCurrent ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button>
</div>
</div>
<div class="password-field">
<label for="new_password" class="form-label">Nuova password</label>
<div class="password-field-control">
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
<input
id="new_password"
v-model="formData.new_password"
:type="showNew ? 'text' : 'password'"
class="form-control"
autocomplete="new-password"
>
<button
type="button"
class="btn btn-password-toggle"
:aria-label="showNew ? 'Nascondi nuova password' : 'Mostra nuova password'"
@click="showNew = !showNew"
>
<BIIcon :name="showNew ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button>
</div>
<div class="form-text">Minimo 8 caratteri.</div>
</div>
<div class="password-field">
<label for="confirm_password" class="form-label">Conferma nuova password</label>
<div class="password-field-control">
<BIIcon name="it-lock" size="sm" class="password-field-icon" />
<input
id="confirm_password"
v-model="formData.confirm_password"
:type="showConfirm ? 'text' : 'password'"
:class="{ 'is-invalid': formData.confirm_password && formData.confirm_password !== formData.new_password }"
class="form-control"
autocomplete="new-password"
>
<button
type="button"
class="btn btn-password-toggle"
:aria-label="showConfirm ? 'Nascondi conferma password' : 'Mostra conferma password'"
@click="showConfirm = !showConfirm"
>
<BIIcon :name="showConfirm ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button>
</div>
</div>
<div v-if="errorMessage" class="alert alert-danger mb-0" role="alert">
{{ errorMessage }}
</div>
<button
type="submit"
class="btn btn-primary w-100 py-2"
:disabled="!canSubmit || isLoading"
>
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
{{ isLoading ? 'Aggiornamento in corso' : 'Aggiorna password' }}
</button>
</div>
</form>
</div>
</div>
<hr class="my-4">
<!-- 2. NUOVA PASSWORD -->
<div class="form-group mb-3">
<div class="input-group">
<div class="input-group-prepend">
<div class="input-group-text"><BIIcon name="it-lock" size="sm" /></div>
</div>
<input
:type="showNew ? 'text' : 'password'"
class="form-control"
id="new_password"
v-model="formData.new_password"
>
<label for="new_password" :class="{ 'active': formData.new_password }">Nuova password</label>
<div class="input-group-append">
<button type="button" class="btn btn-primary btn-sm" @click="showNew = !showNew">
<BIIcon :name="showNew ? 'it-password-visible' : 'it-password-invisible'" size="sm" color="white" />
</button>
</div>
</div>
<small class="form-text text-muted">Minimo 8 caratteri.</small>
</div>
<!-- 3. CONFERMA PASSWORD -->
<div class="form-group mb-4">
<div class="input-group">
<div class="input-group-prepend">
<div class="input-group-text"><BIIcon name="it-lock" size="sm" /></div>
</div>
<input
:type="showConfirm ? 'text' : 'password'"
class="form-control"
:class="{ 'is-invalid': formData.confirm_password && formData.confirm_password !== formData.new_password }"
id="confirm_password"
v-model="formData.confirm_password"
>
<label for="confirm_password" :class="{ 'active': formData.confirm_password }">Conferma nuova password</label>
<div class="input-group-append">
<button type="button" class="btn btn-primary btn-sm" @click="showConfirm = !showConfirm">
<BIIcon :name="showConfirm ? 'it-password-visible' : 'it-password-invisible'" size="sm" color="white" />
</button>
</div>
</div>
</div>
<div v-if="errorMessage" class="alert alert-danger mb-4" role="alert">
{{ errorMessage }}
</div>
<button
type="submit"
class="btn btn-primary w-100 py-2"
:disabled="!canSubmit || isLoading"
>
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
<span v-else>Aggiorna Password</span>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Correzione per i bottoni occhio in Bootstrap Italia */
.input-group-append .btn {
z-index: 4;
.password-reset-page {
background: linear-gradient(180deg, #f0f6fc 0%, #ffffff 65%);
}
.password-reset-intro {
background: #17324d;
border-radius: 8px;
color: #fff;
padding: 2rem;
}
.password-reset-intro :deep(h1),
.password-reset-intro :deep(p),
.password-reset-intro :deep(span) {
color: inherit;
}
.password-reset-eyebrow {
display: inline-block;
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.04em;
margin-bottom: 1rem;
text-transform: uppercase;
}
.password-field {
display: grid;
gap: 0.5rem;
}
.password-field-control {
align-items: center;
background: #fff;
border: 1px solid #c5d0db;
border-radius: 8px;
display: grid;
gap: 0.5rem;
grid-template-columns: auto minmax(0, 1fr) auto;
padding-left: 0.875rem;
}
.password-field-control:focus-within {
border-color: #06c;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.16);
}
.password-field-icon {
color: #5c6f82;
}
.password-field-control .form-control {
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
min-width: 0;
padding-left: 0;
padding-right: 0;
}
.btn-password-toggle {
align-items: center;
color: #06c;
display: inline-flex;
height: 100%;
justify-content: center;
min-height: 3rem;
padding: 0 0.875rem;
}
/* Stile per gestire correttamente le label di Bootstrap Italia che si sovrappongono */
.form-group label.active {
transform: translateY(-140%) scale(0.9);
color: #006f9d;
.btn-password-toggle:hover,
.btn-password-toggle:focus {
background: rgba(0, 102, 204, 0.08);
color: #06c;
}
.input-group-text {
background-color: #f0f0f0;
border-right: none;
}
.form-control:focus + label {
color: #006f9d;
@media (max-width: 991.98px) {
.password-reset-intro {
padding: 1.5rem;
}
}
</style>
+15 -25
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import CodiceFiscale from 'codice-fiscale-js'
import { ref } from 'vue'
definePageMeta({
@@ -25,22 +26,7 @@ const form = ref({
privacy: false
})
const computeSurnameCode = (surname = '') => {
const s = surname.toUpperCase().replace(/[^A-Z]/g, '')
const consonants = s.replace(/[AEIOU]/g, '')
const vowels = s.replace(/[^AEIOU]/g, '')
return (consonants + vowels + 'XXX').slice(0, 3)
}
const computeNameCode = (name = '') => {
const s = name.toUpperCase().replace(/[^A-Z]/g, '')
const consonants = s.replace(/[AEIOU]/g, '')
const vowels = s.replace(/[^AEIOU]/g, '')
if (consonants.length >= 4) {
return consonants.charAt(0) + consonants.charAt(2) + consonants.charAt(3)
}
return (consonants + vowels + 'XXX').slice(0, 3)
}
const normalizeFiscalCode = (fiscalCode = '') => fiscalCode.trim().toUpperCase()
const handleRegister = async () => {
if (!form.value.privacy) {
@@ -48,13 +34,17 @@ const handleRegister = async () => {
return
}
const expected = computeSurnameCode(form.value.last_name) + computeNameCode(form.value.first_name)
if (form.value.fiscal_code.trim().length >= 6) {
const provided = form.value.fiscal_code.trim().toUpperCase().slice(0, 6)
if (provided !== expected) {
errorMessage.value = `Il Codice Fiscale non sembra corrispondere a nome/cognome. Atteso: ${expected}`
return
}
const fiscalCode = normalizeFiscalCode(form.value.fiscal_code)
const expected = CodiceFiscale.surnameCode(form.value.last_name) + CodiceFiscale.nameCode(form.value.first_name)
if (!CodiceFiscale.check(fiscalCode)) {
errorMessage.value = 'Il Codice Fiscale inserito non è valido.'
return
}
if (fiscalCode.slice(0, 6) !== expected) {
errorMessage.value = 'Il Codice Fiscale non corrisponde a nome e cognome inseriti.'
return
}
isLoading.value = true
@@ -64,7 +54,7 @@ const handleRegister = async () => {
await register({
first_name: form.value.first_name,
last_name: form.value.last_name,
fiscal_code: form.value.fiscal_code.toUpperCase(),
fiscal_code: fiscalCode,
email: form.value.email,
password: form.value.password,
phone: form.value.phone || undefined
@@ -199,4 +189,4 @@ const handleRegister = async () => {
.pe-5 {
padding-right: 3rem !important;
}
</style>
</style>
+215 -46
View File
@@ -29,6 +29,15 @@ 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)
const statusMeta: Record<string, string> = {
OPEN: 'Aperta',
UNDER_REVIEW: 'In revisione',
ASSIGNED: 'Assegnata',
IN_PROGRESS: 'In lavorazione',
RESOLVED: 'Risolta',
REJECTED: 'Respinta',
CLOSED: 'Chiusa'
}
// Form Cambia Stato
const newStatus = ref<ReportStatus>('IN_PROGRESS')
@@ -125,80 +134,132 @@ const formatDate = (dateString: string) => {
<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 class="report-hero py-4">
<div class="container">
<NuxtLink to="/operator/assigned" class="btn btn-sm btn-link text-decoration-none ps-0 mb-2">
&larr; Torna alle assegnazioni
</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">Intervento #{{ reportId }}</span>
<StatusBadge :status="report.status" />
</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>
</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="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">
<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 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">
<h6 class="font-weight-bold mt-4 mb-3">Allegati del cittadino</h6>
<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" style="aspect-ratio: 1;" />
<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>
</div>
<div class="card card-bg mb-4">
<div class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Comunicazioni con il Cittadino</h5>
<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" />
<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="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">
<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>
<h2 class="h6 mb-3">Riepilogo intervento</h2>
<ul class="report-summary-list list-unstyled mb-4">
<li>
<span>Operatore</span>
<strong>{{ summary.operator_id ? `#${summary.operator_id}` : 'Nessuno' }}</strong>
</li>
<li>
<span>Squadra</span>
<strong>{{ summary.assigned_to_team || 'Nessuna' }}</strong>
</li>
<li>
<span>Data prevista</span>
<strong>{{ summary.estimated_resolution_date ? formatDate(summary.estimated_resolution_date) : 'Non impostata' }}</strong>
</li>
</ul>
<div class="report-description">
<h3 class="h6 mb-2">Note interne</h3>
<p class="small mb-0">{{ summary.internal_notes || 'Nessuna nota interna' }}</p>
</div>
</div>
<div class="card-footer bg-white border-top-0 pt-0">
<div class="card-body 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 card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Aggiorna Stato</h5>
<h2 class="h6 mb-3">Aggiorna stato</h2>
<form @submit.prevent="handleStatusUpdate">
<div class="mb-3">
<label class="form-label">Nuovo Stato</label>
@@ -220,10 +281,9 @@ const formatDate = (dateString: string) => {
</div>
</div>
<!-- Cronologia -->
<div class="card card-bg">
<div class="card card-bg border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Cronologia Eventi</h5>
<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">
@@ -238,7 +298,7 @@ const formatDate = (dateString: string) => {
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -286,7 +346,116 @@ const formatDate = (dateString: string) => {
</template>
<style scoped>
.report-hero {
background: linear-gradient(180deg, #f0f6fc 0%, #ffffff 100%);
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: 8px;
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: #f7fafc;
border: 1px solid #e4ebf1;
border-radius: 8px;
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;
}
</style>
@media (max-width: 991.98px) {
.report-detail-grid,
.report-hero-stats {
grid-template-columns: 1fr;
}
.report-sidebar {
position: static;
}
}
</style>
+272 -140
View File
@@ -96,6 +96,15 @@ const handleFlag = async () => {
const isOwner = computed(() => report.value?.user_id === user.value?.id)
const canFeedback = computed(() => report.value?.status === 'RESOLVED' || report.value?.status === 'CLOSED')
const statusMeta: Record<string, { label: string; tone: string }> = {
OPEN: { label: 'Aperta', tone: 'info' },
UNDER_REVIEW: { label: 'In revisione', tone: 'warning' },
ASSIGNED: { label: 'Assegnata', tone: 'info' },
IN_PROGRESS: { label: 'In lavorazione', tone: 'primary' },
RESOLVED: { label: 'Risolta', tone: 'success' },
REJECTED: { label: 'Respinta', tone: 'danger' },
CLOSED: { label: 'Chiusa', tone: 'secondary' },
}
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString('it-IT', {
@@ -110,74 +119,80 @@ const formatDate = (dateString: string): string => {
<template>
<div v-if="report">
<!-- HEADER -->
<div class="bg-light border-bottom py-4">
<div class="report-hero py-4 py-lg-5">
<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">
<div class="row align-items-end g-3">
<div class="col-12 col-lg-8">
<div class="report-hero-meta mb-2">
<span class="report-id">Segnalazione #{{ report.id }}</span>
<StatusBadge :status="report.status" />
<small class="text-muted">
<BIIcon name="it-clock" size="xs" class="me-1" />
Creata il {{ formatDate(report.created_at) }}
</small>
</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]?.label || report.status }}</strong>
</div>
<div class="hero-stat">
<span class="hero-stat-label">Creata</span>
<strong>{{ formatDate(report.created_at) }}</strong>
</div>
</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="row g-4">
<div class="col-12 col-xl-8">
<div class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h6 class="card-title font-weight-bold mb-3">
<div class="report-section-head 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>
<h2 class="h6 mb-0">Posizione e dettagli</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 v-if="report.area_name">
<dt>Area</dt>
<dd>{{ report.area_name }}</dd>
</div>
<div>
<dt>Ultimo aggiornamento</dt>
<dd>{{ formatDate(report.updated_at || report.created_at) }}</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>
<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 v-if="media.length > 0" class="card card-bg border-0 shadow-sm 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">
<div class="report-section-head mb-3">
<BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
Allegati ({{ media.length }})
</h6>
<div class="row">
<h2 class="h6 mb-0">Allegati ({{ media.length }})</h2>
</div>
<div class="row g-3">
<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">
<div v-if="m.media_kind === 'IMAGE'" class="ratio ratio-1x1 bg-light rounded overflow-hidden report-media-frame">
<img :src="runtimeConfig.public.api + m.url" :alt="`Allegato ${Number(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">
<div v-else class="ratio ratio-1x1 bg-dark rounded d-flex align-items-center justify-content-center report-media-frame">
<BIIcon name="it-video" size="xl" color="white" />
</div>
</div>
@@ -185,13 +200,12 @@ const formatDate = (dateString: string): string => {
</div>
</div>
<!-- CRONOLOGIA STATI -->
<div v-if="statusHistory.length > 0" class="card card-bg mb-4">
<div v-if="statusHistory.length > 0" class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h6 class="card-title font-weight-bold mb-4">
<div class="report-section-head mb-4">
<BIIcon name="it-history" size="sm" color="primary" class="me-2" />
Cronologia
</h6>
<h2 class="h6 mb-0">Cronologia</h2>
</div>
<div class="timeline">
<div v-for="item in statusHistory" :key="item.id" class="timeline-item mb-3">
<div class="d-flex gap-3">
@@ -213,108 +227,122 @@ const formatDate = (dateString: string): string => {
</div>
</div>
<!-- MESSAGGI -->
<div class="card card-bg">
<div class="card card-bg border-0 shadow-sm">
<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>
<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">Chat segnalazione</h2>
</div>
<p class="text-muted small mb-0">Usa chat per chiarimenti, aggiornamenti operativi e richieste rapide.</p>
</div>
<span class="badge bg-light text-dark border">{{ messages.length }} messaggi</span>
</div>
<MessageList :messages="messages" />
<MessageComposer @send="handleSendMessage" />
</div>
</div>
</div>
<!-- DESTRA: Sidebar azioni -->
<div class="col-12 col-lg-4">
<div class="col-12 col-xl-4">
<div class="report-sidebar">
<div class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h6 mb-3">Riepilogo pratica</h2>
<ul class="report-summary-list list-unstyled mb-0">
<li>
<span>Cittadino proprietario</span>
<strong>{{ isOwner ? 'Sì' : 'No' }}</strong>
</li>
<li>
<span>Stato corrente</span>
<strong>{{ statusMeta[report.status]?.label || report.status }}</strong>
</li>
<li>
<span>Canale</span>
<strong>Portale Visione</strong>
</li>
</ul>
</div>
</div>
<!-- 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>
<div v-if="canFeedback && isOwner" class="card card-bg border-0 shadow-sm mb-4">
<div class="card-body">
<h2 class="h6 mb-3">Valuta intervento</h2>
<p class="text-muted small mb-3">Lascia valutazione finale quando intervento è concluso.</p>
<button
v-if="!isFeedbackOpen"
type="button"
class="btn btn-primary w-100"
@click="isFeedbackOpen = true"
>
Valuta 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'"
/>
<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-full' : '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-outline-secondary" @click="isFeedbackOpen = false">
Annulla
</button>
</div>
</div>
</template>
</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 class="card card-bg border-0 shadow-sm">
<div class="card-body">
<h2 class="h6 mb-3">Segnala abuso</h2>
<p class="text-muted small mb-3">Usa questo canale solo per contenuti impropri o uso scorretto del servizio.</p>
<textarea
v-model="flagMessage"
class="form-control mb-3"
placeholder="Specifica motivo"
rows="3"
/>
<button
type="button"
class="btn 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>
<!-- 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>
@@ -323,8 +351,102 @@ const formatDate = (dateString: string): string => {
</template>
<style scoped>
.card-highlight {
border-color: #006f9d;
.report-hero {
background: linear-gradient(180deg, #f0f6fc 0%, #ffffff 100%);
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: 8px;
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: #f7fafc;
border: 1px solid #e4ebf1;
border-radius: 8px;
padding: 1rem;
}
.report-media-frame {
border: 1px solid #d9e3ee;
}
.report-chat-header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.report-sidebar {
position: sticky;
top: 1.5rem;
}
.report-summary-list {
display: grid;
gap: 0.875rem;
}
.report-summary-list li {
align-items: baseline;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.timeline-marker {
@@ -364,9 +486,19 @@ const formatDate = (dateString: string): string => {
width: 100%;
height: 100%;
}
@media (max-width: 991.98px) {
.report-detail-grid,
.report-hero-stats {
grid-template-columns: 1fr;
}
.report-sidebar {
position: static;
}
}
</style>
+1
View File
@@ -15,6 +15,7 @@
"@nuxtjs/robots": "6.0.9",
"@nuxtjs/sitemap": "8.0.15",
"bootstrap-italia": "^2.18.1",
"codice-fiscale-js": "^2.3.23",
"leaflet": "^1.9.4",
"nuxt": "^4.4.7",
"vue": "^3.5.35",