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:
Yvv
2026-02-21 15:26:02 +01:00
commit b30e54a8f7
67 changed files with 16723 additions and 0 deletions

View File

@@ -0,0 +1,317 @@
<template>
<div>
<div class="page-header">
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink>
<h1>Contenu CMS</h1>
</div>
<div v-if="error" class="alert alert-error">{{ error }}</div>
<div v-if="success" class="alert alert-success">{{ success }}</div>
<!-- Create new page -->
<div class="card" style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3>Pages de contenu</h3>
<button class="btn btn-primary" @click="showCreate = !showCreate">
{{ showCreate ? 'Annuler' : 'Nouvelle page' }}
</button>
</div>
<div v-if="showCreate" style="margin-bottom: 1rem; padding: 1rem; background: var(--color-bg); border-radius: var(--radius);">
<div class="grid grid-2">
<div class="form-group">
<label>Slug (identifiant URL)</label>
<input v-model="newSlug" class="form-input" placeholder="ex: presentation" pattern="[a-z0-9-]+" />
</div>
<div class="form-group">
<label>Titre</label>
<input v-model="newTitle" class="form-input" placeholder="ex: Presentation de la commune" />
</div>
</div>
<button class="btn btn-primary" @click="createPage" :disabled="!newSlug || !newTitle">
Creer la page
</button>
</div>
<!-- Pages list -->
<div v-if="loading" style="text-align: center; padding: 1rem;">
<div class="spinner" style="margin: 0 auto;"></div>
</div>
<div v-else-if="pages.length === 0" class="alert alert-info">
Aucune page de contenu. Cliquez sur "Nouvelle page" pour en creer une.
</div>
<div v-else>
<div
v-for="page in pages" :key="page.slug"
class="page-item"
:class="{ active: editing?.slug === page.slug }"
@click="startEdit(page)"
>
<div>
<strong>{{ page.title }}</strong>
<span style="color: var(--color-text-muted); font-size: 0.8rem; margin-left: 0.5rem;">
/{{ page.slug }}
</span>
</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">
{{ new Date(page.updated_at).toLocaleDateString('fr-FR') }}
</div>
</div>
</div>
</div>
<!-- Editor -->
<div v-if="editing" class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3>{{ editing.title }} <span style="color: var(--color-text-muted); font-size: 0.8rem;">/{{ editing.slug }}</span></h3>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" @click="previewMode = !previewMode">
{{ previewMode ? 'Editer' : 'Apercu' }}
</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete">Supprimer</button>
</div>
</div>
<div class="form-group">
<label>Titre</label>
<input v-model="editing.title" class="form-input" />
</div>
<div v-if="!previewMode" class="form-group">
<label>Contenu (Markdown)</label>
<textarea
v-model="editing.body_markdown"
class="form-input content-textarea"
rows="15"
placeholder="Redigez votre contenu en Markdown..."
></textarea>
</div>
<div v-else class="preview-box">
<div v-html="renderMarkdown(editing.body_markdown)"></div>
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
<button class="btn btn-primary" @click="savePage" :disabled="saving">
{{ saving ? 'Enregistrement...' : 'Enregistrer' }}
</button>
<button class="btn btn-secondary" @click="editing = null">Fermer</button>
</div>
</div>
<!-- Delete confirmation modal -->
<div v-if="deleting" class="modal-overlay" @click.self="deleting = false">
<div class="card modal-content">
<h3>Supprimer cette page ?</h3>
<p style="margin: 1rem 0; color: var(--color-text-muted);">
Supprimer la page <strong>{{ editing?.title }}</strong> est irreversible.
</p>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button class="btn btn-secondary" @click="deleting = false">Annuler</button>
<button class="btn btn-danger" @click="doDelete">Confirmer</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const api = useApi()
const slug = route.params.slug as string
interface ContentPage {
slug: string
title: string
body_markdown: string
updated_at: string
}
const pages = ref<ContentPage[]>([])
const loading = ref(true)
const error = ref('')
const success = ref('')
const showCreate = ref(false)
const newSlug = ref('')
const newTitle = ref('')
const editing = ref<ContentPage | null>(null)
const previewMode = ref(false)
const saving = ref(false)
const deleting = ref(false)
onMounted(async () => {
await loadPages()
})
async function loadPages() {
loading.value = true
error.value = ''
try {
pages.value = await api.get<ContentPage[]>(`/communes/${slug}/content`)
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function createPage() {
error.value = ''; success.value = ''
try {
const page = await api.put<ContentPage>(
`/communes/${slug}/content/${newSlug.value}`,
{ title: newTitle.value, body_markdown: '' },
)
showCreate.value = false
newSlug.value = ''; newTitle.value = ''
await loadPages()
startEdit(page)
success.value = 'Page creee'
} catch (e: any) {
error.value = e.message
}
}
function startEdit(page: ContentPage) {
editing.value = { ...page }
previewMode.value = false
}
async function savePage() {
if (!editing.value) return
saving.value = true
error.value = ''; success.value = ''
try {
await api.put(
`/communes/${slug}/content/${editing.value.slug}`,
{ title: editing.value.title, body_markdown: editing.value.body_markdown },
)
success.value = 'Page enregistree'
await loadPages()
} catch (e: any) {
error.value = e.message
} finally {
saving.value = false
}
}
function confirmDelete() {
deleting.value = true
}
async function doDelete() {
if (!editing.value) return
error.value = ''; success.value = ''
try {
await api.delete(`/communes/${slug}/content/${editing.value.slug}`)
success.value = 'Page supprimee'
editing.value = null
deleting.value = false
await loadPages()
} catch (e: any) {
error.value = e.message
}
}
function renderMarkdown(md: string): string {
if (!md) return '<p style="color: var(--color-text-muted);">Aucun contenu.</p>'
// Simple markdown rendering (headings, bold, italic, links, paragraphs, lists)
return md
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(?!<[hulo])(.+)$/gm, '<p>$1</p>')
}
</script>
<style scoped>
.page-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.15s;
}
.page-item:hover {
background: var(--color-bg);
}
.page-item.active {
border-color: var(--color-primary);
background: #eff6ff;
}
.content-textarea {
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.85rem;
line-height: 1.6;
resize: vertical;
}
.preview-box {
padding: 1rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
min-height: 200px;
line-height: 1.7;
}
.preview-box h1 { font-size: 1.5rem; margin: 1rem 0 0.5rem; }
.preview-box h2 { font-size: 1.25rem; margin: 0.75rem 0 0.5rem; }
.preview-box h3 { font-size: 1.1rem; margin: 0.5rem 0 0.25rem; }
.preview-box p { margin: 0.5rem 0; }
.preview-box ul { margin: 0.5rem 0; padding-left: 1.5rem; }
.preview-box a { color: var(--color-primary); }
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
.alert-success {
background: #dcfce7;
color: #166534;
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-content {
max-width: 480px;
width: 90%;
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div>
<div class="page-header">
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink>
<h1>Import des foyers</h1>
</div>
<div class="card" style="max-width: 700px;">
<p style="margin-bottom: 1rem;">
Importez un fichier CSV ou XLSX avec les colonnes :
<code>identifier, status, volume_m3, price_eur</code>
</p>
<a :href="`${apiBase}/communes/${slug}/households/template`" class="btn btn-secondary" style="margin-bottom: 1rem;">
Télécharger le template
</a>
<div class="form-group">
<label>Fichier (CSV ou XLSX)</label>
<input type="file" accept=".csv,.xlsx,.xls" @change="onFileChange" class="form-input" />
</div>
<!-- Preview -->
<div v-if="preview" style="margin: 1rem 0;">
<div v-if="preview.errors.length" class="alert alert-error">
<strong>Erreurs :</strong>
<ul style="margin: 0.5rem 0 0 1rem;">
<li v-for="err in preview.errors" :key="err">{{ err }}</li>
</ul>
</div>
<div v-else class="alert alert-success">
{{ preview.valid_rows }} foyers valides prêts à importer.
</div>
</div>
<!-- Result -->
<div v-if="result" class="alert alert-success">
{{ result.created }} foyers importés.
<span v-if="result.errors.length"> ({{ result.errors.length }} avertissements)</span>
</div>
<div style="display: flex; gap: 0.5rem;">
<button
class="btn btn-secondary"
:disabled="!file || previewLoading"
@click="doPreview"
>
Vérifier
</button>
<button
class="btn btn-primary"
:disabled="!file || importLoading || (preview && preview.errors.length > 0)"
@click="doImport"
>
Importer
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const config = useRuntimeConfig()
const api = useApi()
const slug = route.params.slug as string
const apiBase = config.public.apiBase as string
const file = ref<File | null>(null)
const preview = ref<any>(null)
const result = ref<any>(null)
const previewLoading = ref(false)
const importLoading = ref(false)
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
file.value = input.files?.[0] || null
preview.value = null
result.value = null
}
async function doPreview() {
if (!file.value) return
previewLoading.value = true
const fd = new FormData()
fd.append('file', file.value)
try {
preview.value = await api.post(`/communes/${slug}/households/import/preview`, fd)
} catch (e: any) {
preview.value = { valid_rows: 0, errors: [e.message], sample: [] }
} finally {
previewLoading.value = false
}
}
async function doImport() {
if (!file.value) return
importLoading.value = true
const fd = new FormData()
fd.append('file', file.value)
try {
result.value = await api.post(`/communes/${slug}/households/import`, fd)
} catch (e: any) {
result.value = { created: 0, errors: [e.message] }
} finally {
importLoading.value = false
}
}
</script>

View 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);">&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>

View File

@@ -0,0 +1,77 @@
<template>
<div>
<div class="page-header">
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink>
<h1>Paramètres tarifs</h1>
</div>
<div v-if="saved" class="alert alert-success">Paramètres enregistrés.</div>
<div v-if="error" class="alert alert-error">{{ error }}</div>
<div class="card" style="max-width: 600px;">
<form @submit.prevent="save">
<div class="form-group">
<label>Recettes cibles ()</label>
<input v-model.number="form.recettes" type="number" class="form-input" step="1000" min="0" />
</div>
<div class="grid grid-2">
<div class="form-group">
<label>Abonnement RP/PRO ()</label>
<input v-model.number="form.abop" type="number" class="form-input" step="1" min="0" />
</div>
<div class="form-group">
<label>Abonnement RS ()</label>
<input v-model.number="form.abos" type="number" class="form-input" step="1" min="0" />
</div>
</div>
<div class="grid grid-2">
<div class="form-group">
<label>Prix max/ ()</label>
<input v-model.number="form.pmax" type="number" class="form-input" step="0.5" min="0" />
</div>
<div class="form-group">
<label>Volume max ()</label>
<input v-model.number="form.vmax" type="number" class="form-input" step="100" min="0" />
</div>
</div>
<button type="submit" class="btn btn-primary" :disabled="loading">
Enregistrer
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const api = useApi()
const slug = route.params.slug as string
const form = reactive({ recettes: 75000, abop: 100, abos: 100, pmax: 20, vmax: 2100 })
const loading = ref(false)
const saved = ref(false)
const error = ref('')
onMounted(async () => {
try {
const params = await api.get<typeof form>(`/communes/${slug}/params`)
Object.assign(form, params)
} catch {}
})
async function save() {
loading.value = true
saved.value = false
error.value = ''
try {
await api.put(`/communes/${slug}/params`, form)
saved.value = true
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div>
<div class="page-header">
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink>
<h1>Votes</h1>
</div>
<!-- Median -->
<div v-if="median" class="card" style="margin-bottom: 1.5rem;">
<h3>Médiane ({{ median.vote_count }} votes)</h3>
<div class="grid grid-4" style="margin-top: 1rem;">
<div><strong>vinf:</strong> {{ median.vinf.toFixed(0) }}</div>
<div><strong>a:</strong> {{ median.a.toFixed(3) }}</div>
<div><strong>b:</strong> {{ median.b.toFixed(3) }}</div>
<div><strong>c:</strong> {{ median.c.toFixed(3) }}</div>
<div><strong>d:</strong> {{ median.d.toFixed(3) }}</div>
<div><strong>e:</strong> {{ median.e.toFixed(3) }}</div>
<div><strong>p0:</strong> {{ median.computed_p0.toFixed(2) }} /</div>
</div>
</div>
<!-- Vote overlay chart placeholder -->
<div class="card" style="margin-bottom: 1.5rem;">
<h3>Overlay des courbes</h3>
<VoteOverlayChart v-if="overlayData.length" :votes="overlayData" :slug="slug" />
<p v-else style="color: var(--color-text-muted); padding: 2rem; text-align: center;">
Aucun vote pour le moment.
</p>
</div>
<!-- Vote list -->
<div class="card">
<h3 style="margin-bottom: 1rem;">Liste des votes actifs</h3>
<table class="table" v-if="votes.length">
<thead>
<tr>
<th>Foyer</th>
<th>vinf</th>
<th>a</th>
<th>b</th>
<th>c</th>
<th>d</th>
<th>e</th>
<th>p0</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr v-for="v in votes" :key="v.id">
<td>#{{ v.household_id }}</td>
<td>{{ v.vinf.toFixed(0) }}</td>
<td>{{ v.a.toFixed(2) }}</td>
<td>{{ v.b.toFixed(2) }}</td>
<td>{{ v.c.toFixed(2) }}</td>
<td>{{ v.d.toFixed(2) }}</td>
<td>{{ v.e.toFixed(2) }}</td>
<td>{{ v.computed_p0?.toFixed(2) }}</td>
<td>{{ new Date(v.submitted_at).toLocaleDateString() }}</td>
</tr>
</tbody>
</table>
<p v-else style="color: var(--color-text-muted);">Aucun vote actif.</p>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const api = useApi()
const slug = route.params.slug as string
const votes = ref<any[]>([])
const median = ref<any>(null)
const overlayData = ref<any[]>([])
onMounted(async () => {
try {
[votes.value, overlayData.value] = await Promise.all([
api.get<any[]>(`/communes/${slug}/votes`),
api.get<any[]>(`/communes/${slug}/votes/overlay`),
])
} catch {}
try {
median.value = await api.get(`/communes/${slug}/votes/median`)
} catch {}
})
</script>

View File

@@ -0,0 +1,225 @@
<template>
<div>
<!-- Redirect commune admin to their commune page -->
<div v-if="!authStore.isSuperAdmin && authStore.communeSlug">
<div class="alert alert-info">
Redirection vers votre espace commune...
</div>
</div>
<!-- Super admin authenticated -->
<template v-else>
<div class="page-header"><h1>Super administration</h1></div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2>Communes</h2>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary" @click="showAdminCreate = !showAdminCreate">
{{ showAdminCreate ? 'Masquer' : 'Nouvel admin commune' }}
</button>
<button class="btn btn-primary" @click="showCreate = true">
Nouvelle commune
</button>
</div>
</div>
<div v-if="error" class="alert alert-error">{{ error }}</div>
<div v-if="success" class="alert alert-success">{{ success }}</div>
<!-- Create commune admin form -->
<div v-if="showAdminCreate" class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin-bottom: 1rem;">Creer un admin commune</h3>
<form @submit.prevent="createAdmin">
<div class="grid grid-2">
<div class="form-group">
<label>Email</label>
<input v-model="newAdmin.email" type="email" class="form-input" required />
</div>
<div class="form-group">
<label>Mot de passe</label>
<input v-model="newAdmin.password" type="password" class="form-input" required />
</div>
</div>
<div class="grid grid-2">
<div class="form-group">
<label>Nom complet</label>
<input v-model="newAdmin.full_name" class="form-input" />
</div>
<div class="form-group">
<label>Commune</label>
<select v-model="newAdmin.commune_slug" class="form-input">
<option value="">-- Aucune --</option>
<option v-for="c in communes" :key="c.id" :value="c.slug">{{ c.name }}</option>
</select>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn btn-primary">Creer l'admin</button>
<button type="button" class="btn btn-secondary" @click="showAdminCreate = false">Annuler</button>
</div>
</form>
</div>
<!-- Create commune form -->
<div v-if="showCreate" class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin-bottom: 1rem;">Creer une commune</h3>
<form @submit.prevent="createCommune">
<div class="grid grid-2">
<div class="form-group">
<label>Nom</label>
<input v-model="newCommune.name" class="form-input" required />
</div>
<div class="form-group">
<label>Slug (URL)</label>
<input v-model="newCommune.slug" class="form-input" required pattern="[a-z0-9-]+" />
</div>
</div>
<div class="form-group">
<label>Description</label>
<input v-model="newCommune.description" class="form-input" />
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn btn-primary">Creer</button>
<button type="button" class="btn btn-secondary" @click="showCreate = false">Annuler</button>
</div>
</form>
</div>
<div v-if="loading" style="text-align: center; padding: 2rem;">
<div class="spinner" style="margin: 0 auto;"></div>
</div>
<div v-else class="grid grid-3">
<div v-for="commune in communes" :key="commune.id" class="card commune-card">
<NuxtLink :to="`/admin/communes/${commune.slug}`" style="text-decoration: none; color: inherit;">
<h3>{{ commune.name }}</h3>
<p style="font-size: 0.875rem; color: var(--color-text-muted);">{{ commune.description }}</p>
<span class="badge" :class="commune.is_active ? 'badge-green' : 'badge-amber'" style="margin-top: 0.5rem;">
{{ commune.is_active ? 'Active' : 'Inactive' }}
</span>
</NuxtLink>
<button
class="btn btn-danger btn-sm"
style="margin-top: 0.75rem;"
@click.prevent="confirmDelete(commune)"
>Supprimer</button>
</div>
</div>
<!-- Delete confirmation modal -->
<div v-if="deletingCommune" class="modal-overlay" @click.self="deletingCommune = null">
<div class="card modal-content">
<h3>Supprimer la commune ?</h3>
<p style="margin: 1rem 0; color: var(--color-text-muted);">
Supprimer <strong>{{ deletingCommune.name }}</strong> effacera toutes les donnees associees
(foyers, votes, parametres). Cette action est irreversible.
</p>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button class="btn btn-secondary" @click="deletingCommune = null">Annuler</button>
<button class="btn btn-danger" @click="doDelete">Confirmer</button>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const authStore = useAuthStore()
const router = useRouter()
const api = useApi()
const communes = ref<any[]>([])
const showCreate = ref(false)
const showAdminCreate = ref(false)
const loading = ref(false)
const error = ref('')
const success = ref('')
const newCommune = reactive({ name: '', slug: '', description: '' })
const newAdmin = reactive({ email: '', password: '', full_name: '', commune_slug: '' })
const deletingCommune = ref<any>(null)
// Redirect commune admin away from super admin page
onMounted(async () => {
if (authStore.isAdmin && !authStore.isSuperAdmin && authStore.communeSlug) {
router.replace(`/admin/communes/${authStore.communeSlug}`)
return
}
if (authStore.isSuperAdmin) {
await loadCommunes()
}
})
async function loadCommunes() {
loading.value = true
try {
communes.value = await api.get<any[]>('/communes/')
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function createCommune() {
error.value = ''; success.value = ''
try {
await api.post('/communes/', newCommune)
showCreate.value = false
newCommune.name = ''; newCommune.slug = ''; newCommune.description = ''
await loadCommunes()
success.value = 'Commune creee'
} catch (e: any) { error.value = e.message }
}
async function createAdmin() {
error.value = ''; success.value = ''
try {
await api.post('/auth/admin/create', {
email: newAdmin.email, password: newAdmin.password,
full_name: newAdmin.full_name, role: 'commune_admin',
commune_slugs: newAdmin.commune_slug ? [newAdmin.commune_slug] : [],
})
showAdminCreate.value = false
newAdmin.email = ''; newAdmin.password = ''; newAdmin.full_name = ''; newAdmin.commune_slug = ''
success.value = 'Admin commune cree'
} catch (e: any) { error.value = e.message }
}
function confirmDelete(c: any) { deletingCommune.value = c }
async function doDelete() {
if (!deletingCommune.value) return
error.value = ''; success.value = ''
try {
await api.delete(`/communes/${deletingCommune.value.slug}`)
success.value = `Commune "${deletingCommune.value.name}" supprimee`
deletingCommune.value = null
await loadCommunes()
} catch (e: any) { error.value = e.message }
}
</script>
<style scoped>
.commune-card { transition: box-shadow 0.15s; }
.commune-card:hover { box-shadow: var(--shadow-md); }
.commune-card h3 { color: var(--color-primary); margin-bottom: 0.25rem; }
.btn-danger {
background: #dc2626; color: white; border: none;
padding: 0.375rem 0.75rem; border-radius: 0.375rem; cursor: pointer; font-size: 0.75rem;
}
.btn-danger:hover { background: #b91c1c; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.alert-success {
background: #dcfce7; color: #166534;
padding: 0.75rem 1rem; border-radius: 0.5rem; margin-bottom: 1rem;
}
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal-content { max-width: 480px; width: 90%; }
</style>