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>
226 lines
8.3 KiB
Vue
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>
|