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:
225
frontend/app/pages/admin/index.vue
Normal file
225
frontend/app/pages/admin/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user