Full-stack app for participatory water pricing using Bezier curves. - Backend: FastAPI + SQLAlchemy + SQLite with JWT auth - Frontend: Nuxt 4 + TypeScript with interactive SVG editor - Math engine: cubic Bezier tarification with Cardano solver - Admin: commune management, household import, vote monitoring, CMS - Citizen: interactive curve editor, vote submission - Docker-compose deployment ready Includes fixes for: - Impact table snake_case/camelCase property mismatch - CMS content backend API + frontend editor (was stub) - Admin route protection middleware - Public content display on commune page - Vote confirmation page link fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
243 lines
7.3 KiB
Vue
243 lines
7.3 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>
|
|
|
|
<!-- 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
|
|
|
|
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}`)
|
|
} 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>
|