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>
This commit is contained in:
242
frontend/app/pages/admin/communes/[slug]/index.vue
Normal file
242
frontend/app/pages/admin/communes/[slug]/index.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user