Files
sejeteralo/frontend/app/pages/admin/communes/[slug]/index.vue
Yvv b30e54a8f7 Initial commit: SejeteralO water tarification platform
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>
2026-02-21 15:26:02 +01:00

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);">&larr; 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--"
>&laquo; 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. &raquo;</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>