Reorganize the citizen page (/commune/[slug]) for voters: full-width interactive chart with "Population" zoom by default, separate auth and vote sections, countdown timer for vote deadline. Backend: add vote_deadline column to communes with Alembic migration. Admin: add deadline configuration card with datetime-local input. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
290 lines
8.9 KiB
Vue
290 lines
8.9 KiB
Vue
<template>
|
|
<div v-if="commune">
|
|
<div class="page-header">
|
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
|
<NuxtLink v-if="authStore.isSuperAdmin" to="/admin" style="color: var(--color-text-muted);">← Admin</NuxtLink>
|
|
<h1>{{ commune.name }}</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-2" style="margin-bottom: 2rem;">
|
|
<NuxtLink :to="`/admin/communes/${slug}/params`" class="card nav-card">
|
|
<h3>Parametres tarifs</h3>
|
|
<p class="nav-card-desc">Configurer les recettes, abonnements, prix max...</p>
|
|
</NuxtLink>
|
|
|
|
<NuxtLink :to="`/admin/communes/${slug}/import`" class="card nav-card">
|
|
<h3>Import foyers</h3>
|
|
<p class="nav-card-desc">Importer les donnees des foyers (CSV/XLSX)</p>
|
|
</NuxtLink>
|
|
|
|
<NuxtLink :to="`/admin/communes/${slug}/votes`" class="card nav-card">
|
|
<h3>Votes</h3>
|
|
<p class="nav-card-desc">Consulter les votes, la mediane et l'overlay</p>
|
|
</NuxtLink>
|
|
|
|
<NuxtLink :to="`/admin/communes/${slug}/content`" class="card nav-card">
|
|
<h3>Contenu CMS</h3>
|
|
<p class="nav-card-desc">Editer le contenu de la page commune</p>
|
|
</NuxtLink>
|
|
</div>
|
|
|
|
<!-- Vote deadline -->
|
|
<div class="card" style="margin-bottom: 2rem;">
|
|
<h3 style="margin-bottom: 1rem;">Parametres du vote</h3>
|
|
<div style="display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap;">
|
|
<div>
|
|
<label style="display: block; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.25rem;">
|
|
Date limite de vote
|
|
</label>
|
|
<input
|
|
v-model="voteDeadline"
|
|
type="datetime-local"
|
|
class="form-input"
|
|
style="width: 260px;"
|
|
/>
|
|
</div>
|
|
<button class="btn btn-primary" @click="saveDeadline" :disabled="savingDeadline">
|
|
<span v-if="savingDeadline" class="spinner" style="width: 1rem; height: 1rem;"></span>
|
|
Enregistrer
|
|
</button>
|
|
<span v-if="deadlineSaved" style="color: #059669; font-size: 0.85rem;">Enregistre !</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div v-if="stats" class="card" style="margin-bottom: 2rem;">
|
|
<h3 style="margin-bottom: 1rem;">Statistiques foyers</h3>
|
|
<div class="grid grid-4">
|
|
<div>
|
|
<div style="font-size: 2rem; font-weight: 700;">{{ stats.total }}</div>
|
|
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Foyers total</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 2rem; font-weight: 700;">{{ stats.voted_count }}</div>
|
|
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Ont vote</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 2rem; font-weight: 700;">{{ stats.avg_volume?.toFixed(1) }}</div>
|
|
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Volume moyen (m3)</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 2rem; font-weight: 700;">{{ stats.median_volume?.toFixed(1) }}</div>
|
|
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Volume median (m3)</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Household codes management -->
|
|
<div class="card">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
|
<h3>Codes foyers</h3>
|
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-input"
|
|
placeholder="Rechercher un foyer..."
|
|
style="width: 220px; padding: 0.375rem 0.75rem; font-size: 0.875rem;"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="householdsLoading" style="text-align: center; padding: 1rem;">
|
|
<div class="spinner" style="margin: 0 auto;"></div>
|
|
</div>
|
|
|
|
<div v-else-if="households.length === 0" class="alert alert-info">
|
|
Aucun foyer importe. Utilisez la page "Import foyers" pour charger les donnees.
|
|
</div>
|
|
|
|
<div v-else>
|
|
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">
|
|
{{ filteredHouseholds.length }} foyer(s) affiche(s) sur {{ households.length }}
|
|
</p>
|
|
<div class="table-scroll">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Identifiant</th>
|
|
<th>Statut</th>
|
|
<th>Volume (m3)</th>
|
|
<th>Code d'acces</th>
|
|
<th>A vote</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="h in paginatedHouseholds" :key="h.id">
|
|
<td>{{ h.identifier }}</td>
|
|
<td>
|
|
<span class="badge" :class="statusBadge(h.status)">{{ h.status }}</span>
|
|
</td>
|
|
<td>{{ h.volume_m3.toFixed(1) }}</td>
|
|
<td>
|
|
<code class="auth-code">{{ h.auth_code }}</code>
|
|
</td>
|
|
<td>
|
|
<span v-if="h.has_voted" style="color: #059669;">Oui</span>
|
|
<span v-else style="color: var(--color-text-muted);">Non</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<!-- Pagination -->
|
|
<div v-if="totalPages > 1" style="display: flex; justify-content: center; gap: 0.5rem; margin-top: 1rem;">
|
|
<button
|
|
class="btn btn-secondary btn-sm"
|
|
:disabled="page === 1"
|
|
@click="page--"
|
|
>« Prec.</button>
|
|
<span style="padding: 0.375rem 0.5rem; font-size: 0.875rem;">
|
|
{{ page }} / {{ totalPages }}
|
|
</span>
|
|
<button
|
|
class="btn btn-secondary btn-sm"
|
|
:disabled="page >= totalPages"
|
|
@click="page++"
|
|
>Suiv. »</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({ middleware: 'admin' })
|
|
|
|
const route = useRoute()
|
|
const authStore = useAuthStore()
|
|
const api = useApi()
|
|
|
|
const slug = route.params.slug as string
|
|
const commune = ref<any>(null)
|
|
const stats = ref<any>(null)
|
|
const households = ref<any[]>([])
|
|
const householdsLoading = ref(false)
|
|
const search = ref('')
|
|
const page = ref(1)
|
|
const perPage = 20
|
|
|
|
// Vote deadline
|
|
const voteDeadline = ref('')
|
|
const savingDeadline = ref(false)
|
|
const deadlineSaved = ref(false)
|
|
|
|
async function saveDeadline() {
|
|
savingDeadline.value = true
|
|
deadlineSaved.value = false
|
|
try {
|
|
await api.put(`/communes/${slug}`, {
|
|
vote_deadline: voteDeadline.value ? new Date(voteDeadline.value).toISOString() : null,
|
|
})
|
|
deadlineSaved.value = true
|
|
} catch (e: any) {
|
|
alert(e.message || 'Erreur')
|
|
} finally {
|
|
savingDeadline.value = false
|
|
}
|
|
}
|
|
|
|
const filteredHouseholds = computed(() => {
|
|
if (!search.value) return households.value
|
|
const q = search.value.toLowerCase()
|
|
return households.value.filter(h =>
|
|
h.identifier.toLowerCase().includes(q) ||
|
|
h.auth_code.toLowerCase().includes(q) ||
|
|
h.status.toLowerCase().includes(q)
|
|
)
|
|
})
|
|
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(filteredHouseholds.value.length / perPage)))
|
|
|
|
const paginatedHouseholds = computed(() => {
|
|
const start = (page.value - 1) * perPage
|
|
return filteredHouseholds.value.slice(start, start + perPage)
|
|
})
|
|
|
|
watch(search, () => { page.value = 1 })
|
|
|
|
function statusBadge(status: string) {
|
|
if (status === 'RS') return 'badge-amber'
|
|
if (status === 'PRO') return 'badge-blue'
|
|
return 'badge-green'
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
commune.value = await api.get<any>(`/communes/${slug}`)
|
|
if (commune.value.vote_deadline) {
|
|
// Format for datetime-local input (YYYY-MM-DDTHH:mm)
|
|
voteDeadline.value = commune.value.vote_deadline.slice(0, 16)
|
|
}
|
|
} catch (e: any) {
|
|
return
|
|
}
|
|
|
|
// Load stats and households in parallel
|
|
householdsLoading.value = true
|
|
try {
|
|
const [s, hh] = await Promise.all([
|
|
api.get<any>(`/communes/${slug}/households/stats`),
|
|
api.get<any[]>(`/communes/${slug}/households`),
|
|
])
|
|
stats.value = s
|
|
households.value = hh
|
|
} catch (e: any) {
|
|
// stats or households may fail if not imported yet
|
|
} finally {
|
|
householdsLoading.value = false
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.nav-card {
|
|
cursor: pointer;
|
|
transition: box-shadow 0.15s;
|
|
}
|
|
|
|
.nav-card:hover {
|
|
box-shadow: var(--shadow-md);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.nav-card h3 {
|
|
color: var(--color-primary);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.nav-card-desc {
|
|
font-size: 0.875rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.auth-code {
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
padding: 0.125rem 0.5rem;
|
|
border-radius: 0.25rem;
|
|
font-family: monospace;
|
|
font-size: 0.9rem;
|
|
letter-spacing: 0.1em;
|
|
user-select: all;
|
|
}
|
|
|
|
.table-scroll {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.badge-blue {
|
|
background: #dbeafe;
|
|
color: #1e40af;
|
|
}
|
|
|
|
.btn-sm {
|
|
padding: 0.25rem 0.75rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
</style>
|