feat(auth): integrate codice-fiscale-js for fiscal code validation in registration
This commit is contained in:
@@ -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 {
|
||||
.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;
|
||||
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>
|
||||
+103
-27
@@ -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) }"
|
||||
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
@@ -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>
|
||||
|
||||
+14
-24
@@ -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
|
||||
|
||||
@@ -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">
|
||||
← 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">
|
||||
← 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>
|
||||
<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>
|
||||
|
||||
<h6 class="font-weight-bold">Descrizione</h6>
|
||||
<p class="text-break bg-white p-3 border rounded">{{ report.description }}</p>
|
||||
<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;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.report-detail-grid,
|
||||
.report-hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.report-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+272
-140
@@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user