Files
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

226 lines
8.3 KiB
Vue

<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>