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
+93 -26
View File
@@ -42,28 +42,17 @@ const removeFile = (idx: number) => {
</script> </script>
<template> <template>
<div class="message-composer mt-4 border-top pt-3"> <div class="message-composer mt-3">
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div v-if="files.length > 0" class="message-files mb-3">
<!-- Preview Allegati --> <div v-for="(file, idx) in files" :key="idx" class="message-file-chip">
<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">
<BIIcon name="it-clip" size="xs" /> <BIIcon name="it-clip" size="xs" />
<span class="text-truncate" style="max-width: 150px;">{{ file.name }}</span> <span class="text-truncate file-name">{{ file.name }}</span>
<button type="button" class="btn-close ms-2" style="font-size: 0.5rem;" @click="removeFile(idx)" aria-label="Rimuovi allegato"></button> <button type="button" class="btn-close btn-close-sm" @click="removeFile(idx)" aria-label="Rimuovi allegato"></button>
</div> </div>
</div> </div>
<div class="input-group align-items-end"> <div class="composer-shell">
<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>
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file" type="file"
@@ -73,17 +62,28 @@ const removeFile = (idx: number) => {
accept="image/*,video/*,.pdf,.doc,.docx" accept="image/*,video/*,.pdf,.doc,.docx"
/> />
<button
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 <textarea
v-model="message" v-model="message"
class="form-control rounded" class="form-control composer-textarea"
placeholder="Scrivi un messaggio... (Ctrl+Enter per inviare)" placeholder="Scrivi aggiornamento o domanda"
rows="2" rows="2"
:disabled="isSubmitting" :disabled="isSubmitting"
@keydown="handleKeydown" @keydown="handleKeydown"
/> />
<button <button
type="submit" type="submit"
class="btn btn-primary ms-2 rounded" class="btn btn-primary composer-submit"
:disabled="isSubmitting || (message.trim() === '' && files.length === 0)" :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> <span v-if="isSubmitting" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
@@ -91,8 +91,9 @@ const removeFile = (idx: number) => {
Invia Invia
</button> </button>
</div> </div>
<small class="text-muted d-block mt-2"> <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> </small>
</form> </form>
</div> </div>
@@ -100,13 +101,79 @@ const removeFile = (idx: number) => {
<style scoped> <style scoped>
.message-composer { .message-composer {
background-color: #f9f9f9; border-top: 1px solid #d9e3ee;
border-radius: 0.5rem; padding-top: 1rem;
padding: 1rem;
} }
textarea { .message-files {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.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; resize: vertical;
font-family: inherit; }
.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> </style>
+103 -27
View File
@@ -28,6 +28,9 @@ const formatDate = (dateString: string): string => {
const auth = useAuth() const auth = useAuth()
const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userId 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> </script>
<template> <template>
@@ -38,38 +41,46 @@ const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userI
</div> </div>
</div> </div>
<div v-else-if="messages.length === 0" class="alert alert-info text-center" role="alert"> <div v-else-if="messages.length === 0" class="message-empty-state" role="alert">
Nessun messaggio disponibile. Sii il primo a scrivere! <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>
<div v-else class="messages-container"> <div v-else class="messages-container">
<div <div
v-for="msg in messages" v-for="msg in messages"
:key="msg.id" :key="msg.id"
class="message-bubble mb-3 d-flex" class="message-row"
:class="{ 'justify-content-end': isCurrentUser(msg.user_id), 'justify-content-start': !isCurrentUser(msg.user_id) }" :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 <div
class="message-bubble-content p-3 rounded-3" class="message-bubble-content"
:class="{ :class="{
'bg-primary text-white': isCurrentUser(msg.user_id), 'message-bubble-outgoing': isCurrentUser(msg.user_id),
'bg-light border': !isCurrentUser(msg.user_id) 'message-bubble-incoming': !isCurrentUser(msg.user_id)
}" }"
> >
<!-- Nome mittente (solo per messaggi non miei) --> <div class="message-meta">
<div v-if="!isCurrentUser(msg.user_id)" class="small font-weight-bold mb-2 text-primary"> <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 }} {{ msg.first_name }} {{ msg.last_name }}
</div> </div>
<!-- Corpo messaggio --> <div class="message-body">
<div class="message-body" :class="{ 'text-break': true }">
{{ msg.body }} {{ msg.body }}
</div> </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> </div>
</div> </div>
@@ -78,34 +89,99 @@ const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userI
<style scoped> <style scoped>
.message-list { .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; overflow-y: auto;
padding: 1rem; padding: 1rem;
background-color: #f9f9f9;
border-radius: 0.5rem;
border: 1px solid #e9ecef;
} }
.messages-container { .messages-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.875rem;
} }
.message-bubble { .message-empty-state {
align-items: center;
color: #5c6f82;
display: flex; 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; 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 { .message-bubble-content {
max-width: 75%; border-radius: 8px;
word-wrap: break-word; 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 { .message-body {
white-space: pre-wrap;
line-height: 1.4; line-height: 1.4;
white-space: pre-wrap;
}
@media (max-width: 767.98px) {
.message-bubble-content {
max-width: calc(100% - 3rem);
}
} }
</style> </style>
+145 -71
View File
@@ -54,91 +54,102 @@ const handleSubmit = async () => {
</script> </script>
<template> <template>
<div class="container my-5"> <div class="password-reset-page py-5">
<div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6"> <div class="col-12 col-xl-10">
<div class="row g-4 align-items-stretch">
<div v-if="user?.must_change_password" class="callout callout-highlight callout-warning mb-4"> <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"> <div class="callout-title d-flex align-items-center">
<BIIcon name="it-warning-circle" size="sm" color="warning" class="me-2" /> <BIIcon name="it-warning-circle" size="sm" color="warning" class="me-2" />
Azione Richiesta Azione richiesta
</div> </div>
<p>Per motivi di sicurezza, imposta una nuova password per il tuo account.</p> <p class="mb-0">Per motivi di sicurezza, imposta una nuova password per il tuo account.</p>
</div> </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>
<div class="card card-bg shadow-sm"> <form @submit.prevent="handleSubmit" class="d-grid gap-4">
<div class="card-body p-4"> <div class="password-field">
<h3 class="card-title h4 mb-4">Cambia la tua password</h3> <label for="current_password" class="form-label">Password attuale</label>
<div class="password-field-control">
<form @submit.prevent="handleSubmit"> <BIIcon name="it-lock" size="sm" class="password-field-icon" />
<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 <input
:type="showCurrent ? 'text' : 'password'"
class="form-control"
id="current_password" id="current_password"
v-model="formData.current_password" v-model="formData.current_password"
:type="showCurrent ? 'text' : 'password'"
class="form-control"
autocomplete="current-password"
> >
<!-- FIX: Aggiunta classe 'active' dinamica per evitare sovrapposizione placeholder --> <button
<label for="current_password" :class="{ 'active': formData.current_password }">Password attuale</label> type="button"
<div class="input-group-append"> class="btn btn-password-toggle"
<button type="button" class="btn btn-primary btn-sm" @click="showCurrent = !showCurrent"> :aria-label="showCurrent ? 'Nascondi password attuale' : 'Mostra password attuale'"
<BIIcon :name="showCurrent ? 'it-password-visible' : 'it-password-invisible'" size="sm" color="white" /> @click="showCurrent = !showCurrent"
>
<BIIcon :name="showCurrent ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button> </button>
</div> </div>
</div> </div>
</div>
<hr class="my-4"> <div class="password-field">
<label for="new_password" class="form-label">Nuova password</label>
<!-- 2. NUOVA PASSWORD --> <div class="password-field-control">
<div class="form-group mb-3"> <BIIcon name="it-lock" size="sm" class="password-field-icon" />
<div class="input-group">
<div class="input-group-prepend">
<div class="input-group-text"><BIIcon name="it-lock" size="sm" /></div>
</div>
<input <input
:type="showNew ? 'text' : 'password'"
class="form-control"
id="new_password" id="new_password"
v-model="formData.new_password" v-model="formData.new_password"
:type="showNew ? 'text' : 'password'"
class="form-control"
autocomplete="new-password"
> >
<label for="new_password" :class="{ 'active': formData.new_password }">Nuova password</label> <button
<div class="input-group-append"> type="button"
<button type="button" class="btn btn-primary btn-sm" @click="showNew = !showNew"> class="btn btn-password-toggle"
<BIIcon :name="showNew ? 'it-password-visible' : 'it-password-invisible'" size="sm" color="white" /> :aria-label="showNew ? 'Nascondi nuova password' : 'Mostra nuova password'"
@click="showNew = !showNew"
>
<BIIcon :name="showNew ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button> </button>
</div> </div>
</div> <div class="form-text">Minimo 8 caratteri.</div>
<small class="form-text text-muted">Minimo 8 caratteri.</small>
</div> </div>
<!-- 3. CONFERMA PASSWORD --> <div class="password-field">
<div class="form-group mb-4"> <label for="confirm_password" class="form-label">Conferma nuova password</label>
<div class="input-group"> <div class="password-field-control">
<div class="input-group-prepend"> <BIIcon name="it-lock" size="sm" class="password-field-icon" />
<div class="input-group-text"><BIIcon name="it-lock" size="sm" /></div>
</div>
<input <input
:type="showConfirm ? 'text' : 'password'"
class="form-control"
:class="{ 'is-invalid': formData.confirm_password && formData.confirm_password !== formData.new_password }"
id="confirm_password" id="confirm_password"
v-model="formData.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"
> >
<label for="confirm_password" :class="{ 'active': formData.confirm_password }">Conferma nuova password</label> <button
<div class="input-group-append"> type="button"
<button type="button" class="btn btn-primary btn-sm" @click="showConfirm = !showConfirm"> class="btn btn-password-toggle"
<BIIcon :name="showConfirm ? 'it-password-visible' : 'it-password-invisible'" size="sm" color="white" /> :aria-label="showConfirm ? 'Nascondi conferma password' : 'Mostra conferma password'"
@click="showConfirm = !showConfirm"
>
<BIIcon :name="showConfirm ? 'it-password-visible' : 'it-password-invisible'" size="sm" />
</button> </button>
</div> </div>
</div> </div>
</div>
<div v-if="errorMessage" class="alert alert-danger mb-4" role="alert"> <div v-if="errorMessage" class="alert alert-danger mb-0" role="alert">
{{ errorMessage }} {{ errorMessage }}
</div> </div>
@@ -148,37 +159,100 @@ const handleSubmit = async () => {
:disabled="!canSubmit || isLoading" :disabled="!canSubmit || isLoading"
> >
<span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span> <span v-if="isLoading" class="spinner-border spinner-border-sm me-2"></span>
<span v-else>Aggiorna Password</span> {{ isLoading ? 'Aggiornamento in corso' : 'Aggiorna password' }}
</button> </button>
</form> </form>
</div> </div>
</div> </div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* Correzione per i bottoni occhio in Bootstrap Italia */ .password-reset-page {
.input-group-append .btn { background: linear-gradient(180deg, #f0f6fc 0%, #ffffff 65%);
z-index: 4; }
.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%; height: 100%;
justify-content: center;
min-height: 3rem;
padding: 0 0.875rem;
} }
/* Stile per gestire correttamente le label di Bootstrap Italia che si sovrappongono */ .btn-password-toggle:hover,
.form-group label.active { .btn-password-toggle:focus {
transform: translateY(-140%) scale(0.9); background: rgba(0, 102, 204, 0.08);
color: #006f9d; color: #06c;
} }
.input-group-text { @media (max-width: 991.98px) {
background-color: #f0f0f0; .password-reset-intro {
border-right: none; padding: 1.5rem;
} }
.form-control:focus + label {
color: #006f9d;
} }
</style> </style>
+12 -22
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import CodiceFiscale from 'codice-fiscale-js'
import { ref } from 'vue' import { ref } from 'vue'
definePageMeta({ definePageMeta({
@@ -25,22 +26,7 @@ const form = ref({
privacy: false privacy: false
}) })
const computeSurnameCode = (surname = '') => { const normalizeFiscalCode = (fiscalCode = '') => fiscalCode.trim().toUpperCase()
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 handleRegister = async () => { const handleRegister = async () => {
if (!form.value.privacy) { if (!form.value.privacy) {
@@ -48,13 +34,17 @@ const handleRegister = async () => {
return return
} }
const expected = computeSurnameCode(form.value.last_name) + computeNameCode(form.value.first_name) const fiscalCode = normalizeFiscalCode(form.value.fiscal_code)
if (form.value.fiscal_code.trim().length >= 6) { const expected = CodiceFiscale.surnameCode(form.value.last_name) + CodiceFiscale.nameCode(form.value.first_name)
const provided = form.value.fiscal_code.trim().toUpperCase().slice(0, 6)
if (provided !== expected) { if (!CodiceFiscale.check(fiscalCode)) {
errorMessage.value = `Il Codice Fiscale non sembra corrispondere a nome/cognome. Atteso: ${expected}` errorMessage.value = 'Il Codice Fiscale inserito non è valido.'
return return
} }
if (fiscalCode.slice(0, 6) !== expected) {
errorMessage.value = 'Il Codice Fiscale non corrisponde a nome e cognome inseriti.'
return
} }
isLoading.value = true isLoading.value = true
@@ -64,7 +54,7 @@ const handleRegister = async () => {
await register({ await register({
first_name: form.value.first_name, first_name: form.value.first_name,
last_name: form.value.last_name, last_name: form.value.last_name,
fiscal_code: form.value.fiscal_code.toUpperCase(), fiscal_code: fiscalCode,
email: form.value.email, email: form.value.email,
password: form.value.password, password: form.value.password,
phone: form.value.phone || undefined phone: form.value.phone || undefined
+210 -41
View File
@@ -29,6 +29,15 @@ const media = computed(() => (reportData.value as any)?.media || [])
const statusHistory = computed(() => (reportData.value as any)?.history || []) const statusHistory = computed(() => (reportData.value as any)?.history || [])
const messages = computed(() => (messagesData.value as any)?.messages || []) 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 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 // Form Cambia Stato
const newStatus = ref<ReportStatus>('IN_PROGRESS') const newStatus = ref<ReportStatus>('IN_PROGRESS')
@@ -125,80 +134,132 @@ const formatDate = (dateString: string) => {
<template> <template>
<div v-if="report"> <div v-if="report">
<div class="bg-light border-bottom py-3"> <div class="report-hero py-4">
<div class="container d-flex justify-content-between align-items-center"> <div class="container">
<div> <NuxtLink to="/operator/assigned" class="btn btn-sm btn-link text-decoration-none ps-0 mb-2">
<NuxtLink to="/operator/assigned" class="btn btn-sm btn-link text-decoration-none ps-0">
&larr; Torna alle assegnazioni &larr; Torna alle assegnazioni
</NuxtLink> </NuxtLink>
<h1 class="h4 font-weight-bold mb-1">Intervento #{{ reportId }}</h1> <div class="row g-3 align-items-end">
<div class="d-flex align-items-center gap-2"> <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" /> <StatusBadge :status="report.status" />
<small class="text-muted">{{ report.category_name }}</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] || 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> </div>
</div> </div>
<div class="container my-5"> <div class="container my-5">
<div class="row"> <div class="row g-4">
<!-- Sinistra: Dettagli --> <div class="col-12 col-xl-7">
<div class="col-12 col-lg-7 mb-4"> <div class="card card-bg border-0 shadow-sm mb-4">
<div class="card card-bg mb-4">
<div class="card-body"> <div class="card-body">
<h5 class="card-title font-weight-bold mb-3">Dettagli Cittadino</h5> <div class="report-section-head mb-3">
<p class="mb-1"><strong>Indirizzo:</strong> {{ report.address }}</p> <BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
<p class="mb-3"><strong>Posizione:</strong> {{ report.latitude }}, {{ report.longitude }}</p> <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>
<h6 class="font-weight-bold">Descrizione</h6> <div class="report-description mt-4">
<p class="text-break bg-white p-3 border rounded">{{ report.description }}</p> <h3 class="h6 mb-2">Descrizione</h3>
<p class="mb-0 text-break">{{ report.description }}</p>
</div>
<div v-if="media.length > 0"> <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 class="row g-2">
<div v-for="m in media" :key="m.id" class="col-4 col-md-3"> <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>
</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"> <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" /> <MessageList :messages="messages" />
<MessageComposer @send="handleSendMessage" /> <MessageComposer @send="handleSendMessage" />
</div> </div>
</div> </div>
</div> </div>
<!-- Destra: Strumenti Operatore --> <div class="col-12 col-xl-5">
<div class="col-12 col-lg-5"> <div class="report-sidebar">
<div class="card card-bg border-0 shadow-sm mb-4" v-if="summary">
<!-- 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"> <div class="card-body">
<p class="mb-2"><strong>Operatore Assegnato:</strong> {{ summary.operator_id ? `#${summary.operator_id}` : 'Nessuno' }}</p> <h2 class="h6 mb-3">Riepilogo intervento</h2>
<p class="mb-2"><strong>Squadra Assegnata:</strong> <span class="badge bg-secondary">{{ summary.assigned_to_team || 'Nessuna' }}</span></p> <ul class="report-summary-list list-unstyled mb-4">
<p class="mb-2"><strong>Data Prevista:</strong> {{ summary.estimated_resolution_date ? formatDate(summary.estimated_resolution_date) : 'Non impostata' }}</p> <li>
<p class="mb-0"><strong>Note Interne:</strong></p> <span>Operatore</span>
<p class="small text-muted bg-light p-2 rounded border mt-1 mb-0">{{ summary.internal_notes || 'Nessuna nota interna' }}</p> <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>
<div class="card-body pt-0">
<button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true"> <button class="btn btn-outline-primary w-100" @click="isAssignModalOpen = true">
Gestisci Squadra e Note Gestisci Squadra e Note
</button> </button>
</div> </div>
</div> </div>
<!-- Modifica Stato --> <div class="card card-bg border-0 shadow-sm mb-4">
<div class="card mb-4">
<div class="card-body"> <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"> <form @submit.prevent="handleStatusUpdate">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Nuovo Stato</label> <label class="form-label">Nuovo Stato</label>
@@ -220,10 +281,9 @@ const formatDate = (dateString: string) => {
</div> </div>
</div> </div>
<!-- Cronologia --> <div class="card card-bg border-0 shadow-sm">
<div class="card card-bg">
<div class="card-body"> <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> <div v-if="statusHistory.length === 0" class="small text-muted">Nessun evento registrato.</div>
<ul class="list-unstyled mb-0 ms-2"> <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"> <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> </ul>
</div> </div>
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -286,7 +346,116 @@ const formatDate = (dateString: string) => {
</template> </template>
<style scoped> <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 {
object-fit: cover; object-fit: cover;
} }
@media (max-width: 991.98px) {
.report-detail-grid,
.report-hero-stats {
grid-template-columns: 1fr;
}
.report-sidebar {
position: static;
}
}
</style> </style>
+223 -91
View File
@@ -96,6 +96,15 @@ const handleFlag = async () => {
const isOwner = computed(() => report.value?.user_id === user.value?.id) const isOwner = computed(() => report.value?.user_id === user.value?.id)
const canFeedback = computed(() => report.value?.status === 'RESOLVED' || report.value?.status === 'CLOSED') 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 => { const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString('it-IT', { return new Date(dateString).toLocaleDateString('it-IT', {
@@ -110,74 +119,80 @@ const formatDate = (dateString: string): string => {
<template> <template>
<div v-if="report"> <div v-if="report">
<!-- HEADER --> <div class="report-hero py-4 py-lg-5">
<div class="bg-light border-bottom py-4">
<div class="container"> <div class="container">
<div class="row"> <div class="row align-items-end g-3">
<div class="col-12 col-md-8"> <div class="col-12 col-lg-8">
<h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1> <div class="report-hero-meta mb-2">
<div class="d-flex align-items-center gap-3"> <span class="report-id">Segnalazione #{{ report.id }}</span>
<StatusBadge :status="report.status" /> <StatusBadge :status="report.status" />
<small class="text-muted"> </div>
<BIIcon name="it-clock" size="xs" class="me-1" /> <h1 class="h3 font-weight-bold mb-2">{{ report.category_name }}</h1>
Creata il {{ formatDate(report.created_at) }} <p class="mb-0 report-hero-address">{{ report.address }}</p>
</small> </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> </div>
</div> </div>
</div> </div>
<!-- CONTENUTO PRINCIPALE -->
<div class="container my-5"> <div class="container my-5">
<div class="row"> <div class="row g-4">
<div class="col-12 col-xl-8">
<!-- SINISTRA: Dettagli segnalazione --> <div class="card card-bg border-0 shadow-sm mb-4">
<div class="col-12 col-lg-8 mb-4">
<!-- INDIRIZZO E COORDINATE -->
<div class="card card-bg mb-4">
<div class="card-body"> <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" /> <BIIcon name="it-map-marker-circle" size="sm" color="primary" class="me-2" />
Posizione <h2 class="h6 mb-0">Posizione e dettagli</h2>
</h6> </div>
<div class="mb-2"> <dl class="report-detail-grid mb-0">
<strong>{{ report.address }}</strong> <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> </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>
</div> </div>
<!-- DESCRIZIONE --> <div v-if="media.length > 0" class="card card-bg border-0 shadow-sm mb-4">
<div class="card card-bg mb-4">
<div class="card-body"> <div class="card-body">
<h6 class="card-title font-weight-bold mb-3"> <div class="report-section-head 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" /> <BIIcon name="it-camera" size="sm" color="primary" class="me-2" />
Allegati ({{ media.length }}) <h2 class="h6 mb-0">Allegati ({{ media.length }})</h2>
</h6> </div>
<div class="row"> <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-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" /> <img :src="runtimeConfig.public.api + m.url" :alt="`Allegato ${Number(idx) + 1}`" class="object-fit-cover" />
</div> </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" /> <BIIcon name="it-video" size="xl" color="white" />
</div> </div>
</div> </div>
@@ -185,13 +200,12 @@ const formatDate = (dateString: string): string => {
</div> </div>
</div> </div>
<!-- CRONOLOGIA STATI --> <div v-if="statusHistory.length > 0" class="card card-bg border-0 shadow-sm mb-4">
<div v-if="statusHistory.length > 0" class="card card-bg mb-4">
<div class="card-body"> <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" /> <BIIcon name="it-history" size="sm" color="primary" class="me-2" />
Cronologia <h2 class="h6 mb-0">Cronologia</h2>
</h6> </div>
<div class="timeline"> <div class="timeline">
<div v-for="item in statusHistory" :key="item.id" class="timeline-item mb-3"> <div v-for="item in statusHistory" :key="item.id" class="timeline-item mb-3">
<div class="d-flex gap-3"> <div class="d-flex gap-3">
@@ -213,36 +227,57 @@ const formatDate = (dateString: string): string => {
</div> </div>
</div> </div>
<!-- MESSAGGI --> <div class="card card-bg border-0 shadow-sm">
<div class="card card-bg">
<div class="card-body"> <div class="card-body">
<h6 class="card-title font-weight-bold mb-3"> <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" /> <BIIcon name="it-comment" size="sm" color="primary" class="me-2" />
Chat ({{ messages.length }}) <h2 class="h6 mb-0">Chat segnalazione</h2>
</h6> </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" /> <MessageList :messages="messages" />
<MessageComposer @send="handleSendMessage" /> <MessageComposer @send="handleSendMessage" />
</div> </div>
</div> </div>
</div> </div>
<!-- DESTRA: Sidebar azioni --> <div class="col-12 col-xl-4">
<div class="col-12 col-lg-4"> <div class="report-sidebar">
<div class="card card-bg border-0 shadow-sm mb-4">
<!-- FEEDBACK (se risolta) -->
<div v-if="canFeedback && isOwner" class="card card-bg card-highlight mb-4">
<div class="card-body"> <div class="card-body">
<h6 class="card-title font-weight-bold mb-3"> <h2 class="h6 mb-3">Riepilogo pratica</h2>
<BIIcon name="it-star-outline" size="sm" color="primary" class="me-2" /> <ul class="report-summary-list list-unstyled mb-0">
Lascia un feedback <li>
</h6> <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>
<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 <button
v-if="!isFeedbackOpen" v-if="!isFeedbackOpen"
type="button" type="button"
class="btn btn-primary w-100" class="btn btn-primary w-100"
@click="isFeedbackOpen = true" @click="isFeedbackOpen = true"
> >
Valuta l'intervento Valuta intervento
</button> </button>
<template v-else> <template v-else>
@@ -252,7 +287,7 @@ const formatDate = (dateString: string): string => {
<button v-for="n in 5" :key="n" type="button" class="btn btn-link p-0" <button v-for="n in 5" :key="n" type="button" class="btn btn-link p-0"
@click="feedbackRating = n" :aria-label="`${n} stelle`"> @click="feedbackRating = n" :aria-label="`${n} stelle`">
<BIIcon <BIIcon
:name="n <= feedbackRating ? 'it-star' : 'it-star-outline'" :name="n <= feedbackRating ? 'it-star-full' : 'it-star-outline'"
size="lg" size="lg"
:color="n <= feedbackRating ? 'warning' : 'primary'" :color="n <= feedbackRating ? 'warning' : 'primary'"
/> />
@@ -264,7 +299,7 @@ const formatDate = (dateString: string): string => {
<textarea <textarea
v-model="feedbackComment" v-model="feedbackComment"
class="form-control" class="form-control"
placeholder="Commento (opzionale)" placeholder="Commento opzionale"
rows="3" rows="3"
/> />
</div> </div>
@@ -274,30 +309,27 @@ const formatDate = (dateString: string): string => {
<span v-if="isSubmittingFeedback" class="spinner-border spinner-border-sm me-2"></span> <span v-if="isSubmittingFeedback" class="spinner-border spinner-border-sm me-2"></span>
Invia Invia
</button> </button>
<button type="button" class="btn btn-light" @click="isFeedbackOpen = false"> <button type="button" class="btn btn-outline-secondary" @click="isFeedbackOpen = false">
Cancella Annulla
</button> </button>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<!-- SEGNALA ABUSO --> <div class="card card-bg border-0 shadow-sm">
<div class="card card-bg">
<div class="card-body"> <div class="card-body">
<h6 class="card-title font-weight-bold mb-3"> <h2 class="h6 mb-3">Segnala abuso</h2>
<BIIcon name="it-flag" size="sm" color="danger" class="me-2" /> <p class="text-muted small mb-3">Usa questo canale solo per contenuti impropri o uso scorretto del servizio.</p>
Segnala abuso
</h6>
<textarea <textarea
v-model="flagMessage" v-model="flagMessage"
class="form-control form-control-sm mb-2" class="form-control mb-3"
placeholder="Specifica il motivo dell'abuso..." placeholder="Specifica motivo"
rows="2" rows="3"
/> />
<button <button
type="button" type="button"
class="btn btn-sm btn-outline-danger w-100" class="btn btn-outline-danger w-100"
@click="handleFlag" @click="handleFlag"
:disabled="isFlagging || !flagMessage.trim()" :disabled="isFlagging || !flagMessage.trim()"
> >
@@ -306,15 +338,11 @@ const formatDate = (dateString: string): string => {
</button> </button>
</div> </div>
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- LOADING STATE --> </div>
<div v-else class="container my-5 text-center"> <div v-else class="container my-5 text-center">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Caricamento...</span> <span class="visually-hidden">Caricamento...</span>
@@ -323,8 +351,102 @@ const formatDate = (dateString: string): string => {
</template> </template>
<style scoped> <style scoped>
.card-highlight { .report-hero {
border-color: #006f9d; 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 { .timeline-marker {
@@ -364,9 +486,19 @@ const formatDate = (dateString: string): string => {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
@media (max-width: 991.98px) {
.report-detail-grid,
.report-hero-stats {
grid-template-columns: 1fr;
}
.report-sidebar {
position: static;
}
}
</style> </style>
+1
View File
@@ -15,6 +15,7 @@
"@nuxtjs/robots": "6.0.9", "@nuxtjs/robots": "6.0.9",
"@nuxtjs/sitemap": "8.0.15", "@nuxtjs/sitemap": "8.0.15",
"bootstrap-italia": "^2.18.1", "bootstrap-italia": "^2.18.1",
"codice-fiscale-js": "^2.3.23",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"nuxt": "^4.4.7", "nuxt": "^4.4.7",
"vue": "^3.5.35", "vue": "^3.5.35",