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:
317
frontend/app/pages/admin/communes/[slug]/content.vue
Normal file
317
frontend/app/pages/admin/communes/[slug]/content.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">← {{ 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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>
|
||||
111
frontend/app/pages/admin/communes/[slug]/import.vue
Normal file
111
frontend/app/pages/admin/communes/[slug]/import.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">← {{ 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>
|
||||
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>
|
||||
77
frontend/app/pages/admin/communes/[slug]/params.vue
Normal file
77
frontend/app/pages/admin/communes/[slug]/params.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">← {{ 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/m³ (€)</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 (m³)</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>
|
||||
89
frontend/app/pages/admin/communes/[slug]/votes.vue
Normal file
89
frontend/app/pages/admin/communes/[slug]/votes.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">← {{ 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) }} €/m³</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>
|
||||
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>
|
||||
11
frontend/app/pages/commune/[slug]/citizen.vue
Normal file
11
frontend/app/pages/commune/[slug]/citizen.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Redirect to main commune page (editor is now integrated there)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const slug = route.params.slug as string
|
||||
onMounted(() => router.replace(`/commune/${slug}`))
|
||||
</script>
|
||||
724
frontend/app/pages/commune/[slug]/index.vue
Normal file
724
frontend/app/pages/commune/[slug]/index.vue
Normal file
@@ -0,0 +1,724 @@
|
||||
<template>
|
||||
<div v-if="commune">
|
||||
<div class="page-header">
|
||||
<NuxtLink to="/" style="color: var(--color-text-muted); font-size: 0.875rem;">← Toutes les communes</NuxtLink>
|
||||
<h1>{{ commune.name }}</h1>
|
||||
<p style="color: var(--color-text-muted);">{{ commune.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="card" style="text-align: center; padding: 3rem;">
|
||||
<div class="spinner" style="margin: 0 auto;"></div>
|
||||
<p style="margin-top: 1rem; color: var(--color-text-muted);">Chargement...</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="curveData">
|
||||
<!-- CMS content (published by admin) -->
|
||||
<div v-if="contentPages.length" style="margin-bottom: 1.5rem;">
|
||||
<div v-for="page in contentPages" :key="page.slug" class="card" style="margin-bottom: 1rem;">
|
||||
<h3 style="margin-bottom: 0.5rem;">{{ page.title }}</h3>
|
||||
<div class="cms-body" v-html="renderMarkdown(page.body_markdown)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
GRAPH 1: Interactive Bezier curve — Prix au m3
|
||||
(= dernier graph de eau.py — NewModel bottom subplot)
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="card" style="margin-bottom: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<h3>Tarification progressive — Prix au m<sup>3</sup></h3>
|
||||
<span v-if="curveData.has_votes" class="badge badge-green">
|
||||
Mediane de {{ curveData.vote_count }} vote(s)
|
||||
</span>
|
||||
<span v-else class="badge badge-amber">Courbe par defaut</span>
|
||||
</div>
|
||||
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 1rem;">
|
||||
Deplacez les poignees pour ajuster la forme de la courbe.
|
||||
Le prix d'inflexion p<sub>0</sub> s'ajuste automatiquement pour equilibrer les recettes.
|
||||
</p>
|
||||
|
||||
<div class="editor-layout">
|
||||
<!-- SVG: Prix au m3 vs Volume (the Bezier curve) -->
|
||||
<div class="chart-container">
|
||||
<svg
|
||||
ref="svgRef"
|
||||
:viewBox="`0 0 ${W} ${H}`"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp"
|
||||
@touchmove.prevent="onTouchMove"
|
||||
@touchend="onMouseUp"
|
||||
>
|
||||
<!-- Grid -->
|
||||
<g>
|
||||
<line v-for="v in gridVols" :key="'gv'+v"
|
||||
:x1="cx(v)" :y1="cy(0)" :x2="cx(v)" :y2="cy(pmax)"
|
||||
stroke="#e2e8f0" stroke-width="0.5" />
|
||||
<line v-for="p in gridPrices" :key="'gp'+p"
|
||||
:x1="cx(0)" :y1="cy(p)" :x2="cx(vmax)" :y2="cy(p)"
|
||||
stroke="#e2e8f0" stroke-width="0.5" />
|
||||
<!-- Volume labels -->
|
||||
<text v-for="v in gridVols" :key="'lv'+v"
|
||||
:x="cx(v)" :y="H - 2" text-anchor="middle" font-size="10" fill="#94a3b8">
|
||||
{{ v }}
|
||||
</text>
|
||||
<!-- Price labels -->
|
||||
<text v-for="p in gridPrices" :key="'lp'+p"
|
||||
:x="margin.left - 4" :y="cy(p) + 3" text-anchor="end" font-size="10" fill="#94a3b8">
|
||||
{{ p }}
|
||||
</text>
|
||||
<!-- Axes labels -->
|
||||
<text :x="W / 2" :y="H - 0" text-anchor="middle" font-size="10" fill="#64748b">
|
||||
volume (m3)
|
||||
</text>
|
||||
<text :x="12" :y="margin.top - 4" font-size="10" fill="#64748b">
|
||||
Prix/m3
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Tangent lines (control arms) -->
|
||||
<line :x1="cx(cp.p1.x)" :y1="cy(cp.p1.y)" :x2="cx(cp.p2.x)" :y2="cy(cp.p2.y)"
|
||||
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
|
||||
<line :x1="cx(cp.p3.x)" :y1="cy(cp.p3.y)" :x2="cx(cp.p4.x)" :y2="cy(cp.p4.y)"
|
||||
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
|
||||
<line :x1="cx(cp.p4.x)" :y1="cy(cp.p4.y)" :x2="cx(cp.p5.x)" :y2="cy(cp.p5.y)"
|
||||
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
|
||||
<line :x1="cx(cp.p6.x)" :y1="cy(cp.p6.y)" :x2="cx(cp.p7.x)" :y2="cy(cp.p7.y)"
|
||||
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
|
||||
|
||||
<!-- Bezier curve: tier 1 (blue) -->
|
||||
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="2.5" />
|
||||
<!-- Bezier curve: tier 2 (red) -->
|
||||
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2.5" />
|
||||
|
||||
<!-- Inflection reference lines -->
|
||||
<line :x1="cx(bp.vinf)" :y1="cy(0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
|
||||
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
||||
<line :x1="cx(0)" :y1="cy(localP0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
|
||||
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
||||
|
||||
<!-- p0 label -->
|
||||
<text :x="cx(0) + 30" :y="cy(localP0) - 6" font-size="12" fill="#1e293b" font-weight="600">
|
||||
p0 = {{ localP0.toFixed(2) }} EUR/m3
|
||||
</text>
|
||||
|
||||
<!-- Draggable control points -->
|
||||
<circle v-for="(pt, key) in dragPoints" :key="key"
|
||||
:cx="cx(pt.x)" :cy="cy(pt.y)"
|
||||
:r="dragging === key ? 9 : 7"
|
||||
:fill="ptColors[key]" stroke="white" stroke-width="2"
|
||||
style="cursor: grab;"
|
||||
@mousedown.prevent="startDrag(key)"
|
||||
@touchstart.prevent="startDrag(key)"
|
||||
/>
|
||||
<!-- Point labels -->
|
||||
<text v-for="(pt, key) in dragPoints" :key="'l-'+key"
|
||||
:x="cx(pt.x) + 10" :y="cy(pt.y) - 10"
|
||||
font-size="11" :fill="ptColors[key]" font-weight="500">
|
||||
{{ ptLabels[key] }}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: parameters + impacts -->
|
||||
<div class="side-panel">
|
||||
<div class="card" style="margin-bottom: 1rem;">
|
||||
<h4 style="margin-bottom: 0.5rem;">Parametres de la courbe</h4>
|
||||
<div class="param-grid">
|
||||
<div class="param-row">
|
||||
<span class="param-label">v<sub>inf</sub></span>
|
||||
<span class="param-val">{{ bp.vinf.toFixed(0) }} m3</span>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<span class="param-label">a</span>
|
||||
<span class="param-val">{{ bp.a.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<span class="param-label">b</span>
|
||||
<span class="param-val">{{ bp.b.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<span class="param-label">c</span>
|
||||
<span class="param-val">{{ bp.c.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<span class="param-label">d</span>
|
||||
<span class="param-val">{{ bp.d.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<span class="param-label">e</span>
|
||||
<span class="param-val">{{ bp.e.toFixed(3) }}</span>
|
||||
</div>
|
||||
<div class="param-row" style="grid-column: span 2; border-top: 1px solid var(--color-border); padding-top: 0.5rem;">
|
||||
<span class="param-label" style="font-weight: 600;">p<sub>0</sub></span>
|
||||
<span class="param-val" style="font-size: 1.1rem;">{{ localP0.toFixed(2) }} EUR/m3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Impact table -->
|
||||
<div class="card" style="margin-bottom: 1rem;">
|
||||
<h4 style="margin-bottom: 0.5rem;">Impact par volume</h4>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr><th>Vol.</th><th>Ancien</th><th>Nouveau RP</th><th>Nouveau RS</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="imp in impacts" :key="imp.volume">
|
||||
<td>{{ imp.volume }} m3</td>
|
||||
<td>{{ imp.old_price.toFixed(0) }} EUR</td>
|
||||
<td :class="imp.new_price_rp > imp.old_price ? 'text-up' : 'text-down'">
|
||||
{{ imp.new_price_rp.toFixed(0) }} EUR
|
||||
</td>
|
||||
<td :class="imp.new_price_rs > imp.old_price ? 'text-up' : 'text-down'">
|
||||
{{ imp.new_price_rs.toFixed(0) }} EUR
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Vote action -->
|
||||
<div class="card">
|
||||
<div v-if="!isCitizenAuth">
|
||||
<p style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">
|
||||
Pour soumettre votre vote, entrez votre code foyer :
|
||||
</p>
|
||||
<div v-if="authError" class="alert alert-error" style="margin-bottom: 0.5rem;">{{ authError }}</div>
|
||||
<form @submit.prevent="authenticate" style="display: flex; gap: 0.5rem;">
|
||||
<input v-model="authCode" type="text" maxlength="8" placeholder="Code foyer"
|
||||
class="form-input" style="flex: 1; text-transform: uppercase; font-family: monospace; letter-spacing: 0.15em;" />
|
||||
<button type="submit" class="btn btn-primary" :disabled="authLoading">OK</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else>
|
||||
<button class="btn btn-primary" style="width: 100%;" @click="submitVote" :disabled="submitting">
|
||||
<span v-if="submitting" class="spinner" style="width: 1rem; height: 1rem;"></span>
|
||||
Soumettre mon vote
|
||||
</button>
|
||||
<div v-if="voteSuccess" class="alert alert-success" style="margin-top: 0.5rem;">
|
||||
Vote enregistre !
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
GRAPH 2: Static baseline — Modele lineaire actuel
|
||||
(= 1er graph de eau.py — CurrentModel)
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="card" style="margin-bottom: 1.5rem;">
|
||||
<h3 style="margin-bottom: 0.5rem;">Tarification actuelle (modele lineaire)</h3>
|
||||
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 1rem;">
|
||||
Situation tarifaire en vigueur : prix fixe de {{ curveData.p0_linear?.toFixed(2) }} EUR/m3 + abonnement.
|
||||
</p>
|
||||
|
||||
<div class="baseline-charts">
|
||||
<!-- Left: Facture totale -->
|
||||
<div class="chart-container">
|
||||
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem;">Facture totale (EUR)</h4>
|
||||
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
|
||||
<!-- Grid -->
|
||||
<g>
|
||||
<line v-for="v in gridVols2" :key="'bg1v'+v"
|
||||
:x1="cx2(v)" :y1="cy2bill(0)" :x2="cx2(v)" :y2="cy2bill(maxBill)"
|
||||
stroke="#e2e8f0" stroke-width="0.5" />
|
||||
<line v-for="b in gridBills" :key="'bg1b'+b"
|
||||
:x1="cx2(0)" :y1="cy2bill(b)" :x2="cx2(vmax)" :y2="cy2bill(b)"
|
||||
stroke="#e2e8f0" stroke-width="0.5" />
|
||||
<text v-for="v in gridVols2" :key="'bg1lv'+v"
|
||||
:x="cx2(v)" :y="H2 - 2" text-anchor="middle" font-size="9" fill="#94a3b8">{{ v }}</text>
|
||||
<text v-for="b in gridBills" :key="'bg1lb'+b"
|
||||
:x="margin2.left - 4" :y="cy2bill(b) + 3" text-anchor="end" font-size="9" fill="#94a3b8">{{ b }}</text>
|
||||
</g>
|
||||
<!-- RP curve -->
|
||||
<polyline :points="baselineBillRP" fill="none" stroke="#2563eb" stroke-width="1.5" />
|
||||
<!-- RS curve -->
|
||||
<polyline :points="baselineBillRS" fill="none" stroke="#dc2626" stroke-width="1.5" />
|
||||
<!-- Legend -->
|
||||
<g :transform="`translate(${W2 - 100}, 15)`">
|
||||
<line x1="0" y1="0" x2="15" y2="0" stroke="#2563eb" stroke-width="1.5" />
|
||||
<text x="18" y="3" font-size="9" fill="#1e293b">RP/PRO</text>
|
||||
<line x1="0" y1="12" x2="15" y2="12" stroke="#dc2626" stroke-width="1.5" />
|
||||
<text x="18" y="15" font-size="9" fill="#1e293b">RS</text>
|
||||
</g>
|
||||
<text :x="W2/2" :y="H2" text-anchor="middle" font-size="9" fill="#64748b">volume (m3)</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Right: Prix au m3 -->
|
||||
<div class="chart-container">
|
||||
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem;">Prix au m<sup>3</sup> (EUR)</h4>
|
||||
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
|
||||
<!-- Grid -->
|
||||
<g>
|
||||
<line v-for="v in gridVols2" :key="'bg2v'+v"
|
||||
:x1="cx2(v)" :y1="cy2price(0)" :x2="cx2(v)" :y2="cy2price(pmax)"
|
||||
stroke="#e2e8f0" stroke-width="0.5" />
|
||||
<line v-for="p in gridPrices2" :key="'bg2p'+p"
|
||||
:x1="cx2(0)" :y1="cy2price(p)" :x2="cx2(vmax)" :y2="cy2price(p)"
|
||||
stroke="#e2e8f0" stroke-width="0.5" />
|
||||
<text v-for="v in gridVols2" :key="'bg2lv'+v"
|
||||
:x="cx2(v)" :y="H2 - 2" text-anchor="middle" font-size="9" fill="#94a3b8">{{ v }}</text>
|
||||
<text v-for="p in gridPrices2" :key="'bg2lp'+p"
|
||||
:x="margin2.left - 4" :y="cy2price(p) + 3" text-anchor="end" font-size="9" fill="#94a3b8">{{ p }}</text>
|
||||
</g>
|
||||
<!-- RP price/m3 curve (hyperbolic) -->
|
||||
<polyline :points="baselinePriceRP" fill="none" stroke="#2563eb" stroke-width="1.5" />
|
||||
<!-- RS price/m3 curve -->
|
||||
<polyline :points="baselinePriceRS" fill="none" stroke="#dc2626" stroke-width="1.5" />
|
||||
<!-- p0 baseline line -->
|
||||
<line :x1="cx2(0)" :y1="cy2price(curveData.p0_linear)" :x2="cx2(vmax)" :y2="cy2price(curveData.p0_linear)"
|
||||
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
||||
<text :x="cx2(vmax) - 5" :y="cy2price(curveData.p0_linear) - 5" text-anchor="end"
|
||||
font-size="10" fill="#475569">
|
||||
p0 = {{ curveData.p0_linear?.toFixed(2) }}
|
||||
</text>
|
||||
<text :x="W2/2" :y="H2" text-anchor="middle" font-size="9" fill="#64748b">volume (m3)</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tariff params info -->
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom: 0.75rem;">Informations tarifaires</h3>
|
||||
<div v-if="params" class="grid grid-5-info">
|
||||
<div><strong>{{ params.recettes.toLocaleString() }} EUR</strong><br/><span class="info-label">Recettes cibles</span></div>
|
||||
<div><strong>{{ params.abop }} EUR</strong><br/><span class="info-label">Abo RP/PRO</span></div>
|
||||
<div><strong>{{ params.abos }} EUR</strong><br/><span class="info-label">Abo RS</span></div>
|
||||
<div><strong>{{ params.pmax }} EUR/m3</strong><br/><span class="info-label">Prix max</span></div>
|
||||
<div><strong>{{ params.vmax }} m3</strong><br/><span class="info-label">Volume max</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="alert alert-error">{{ loadError }}</div>
|
||||
|
||||
<div v-else style="text-align: center; padding: 3rem;">
|
||||
<div class="spinner" style="margin: 0 auto;"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computeP0, computeImpacts, generateCurve,
|
||||
paramsToControlPoints,
|
||||
type HouseholdData, type ImpactRow, type ControlPoints,
|
||||
} from '~/utils/bezier-math'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const api = useApi()
|
||||
|
||||
const slug = route.params.slug as string
|
||||
const commune = ref<any>(null)
|
||||
const params = ref<any>(null)
|
||||
const curveData = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const contentPages = ref<any[]>([])
|
||||
|
||||
// Bezier params (citizen-adjustable)
|
||||
const bp = reactive({ vinf: 1050, a: 0.5, b: 0.5, c: 0.5, d: 0.5, e: 0.5 })
|
||||
const localP0 = ref(0)
|
||||
const impacts = ref<any[]>([])
|
||||
const households = ref<HouseholdData[]>([])
|
||||
|
||||
// Tariff fixed params
|
||||
const vmax = ref(2100)
|
||||
const pmax = ref(20)
|
||||
const recettes = ref(75000)
|
||||
const abop = ref(100)
|
||||
const abos = ref(100)
|
||||
|
||||
// Auth
|
||||
const authCode = ref('')
|
||||
const authError = ref('')
|
||||
const authLoading = ref(false)
|
||||
const isCitizenAuth = computed(() => authStore.isCitizen && authStore.communeSlug === slug)
|
||||
const submitting = ref(false)
|
||||
const voteSuccess = ref(false)
|
||||
|
||||
// ── Chart 1: Interactive Bezier ──
|
||||
const W = 620
|
||||
const H = 380
|
||||
const margin = { top: 20, right: 20, bottom: 28, left: 45 }
|
||||
const plotW = W - margin.left - margin.right
|
||||
const plotH = H - margin.top - margin.bottom
|
||||
|
||||
function cx(v: number) { return margin.left + (v / vmax.value) * plotW }
|
||||
function cy(p: number) { return margin.top + plotH - (p / pmax.value) * plotH }
|
||||
function fromX(sx: number) { return ((sx - margin.left) / plotW) * vmax.value }
|
||||
function fromY(sy: number) { return ((margin.top + plotH - sy) / plotH) * pmax.value }
|
||||
|
||||
const gridVols = computed(() => {
|
||||
const step = Math.ceil(vmax.value / 7 / 100) * 100
|
||||
const arr: number[] = []
|
||||
for (let v = step; v < vmax.value; v += step) arr.push(v)
|
||||
return arr
|
||||
})
|
||||
const gridPrices = computed(() => {
|
||||
const step = Math.ceil(pmax.value / 5)
|
||||
const arr: number[] = []
|
||||
for (let p = step; p <= pmax.value; p += step) arr.push(p)
|
||||
return arr
|
||||
})
|
||||
|
||||
// Control points
|
||||
const cp = computed<ControlPoints>(() =>
|
||||
paramsToControlPoints(bp.vinf, vmax.value, pmax.value, localP0.value, bp.a, bp.b, bp.c, bp.d, bp.e)
|
||||
)
|
||||
|
||||
const dragPoints = computed(() => ({
|
||||
p2: cp.value.p2,
|
||||
p3: cp.value.p3,
|
||||
p4: cp.value.p4,
|
||||
p5: cp.value.p5,
|
||||
p6: cp.value.p6,
|
||||
}))
|
||||
|
||||
const ptColors: Record<string, string> = {
|
||||
p2: '#3b82f6', p3: '#3b82f6', p4: '#8b5cf6', p5: '#ef4444', p6: '#ef4444',
|
||||
}
|
||||
const ptLabels: Record<string, string> = {
|
||||
p2: 'a', p3: 'b', p4: 'vinf', p5: 'c', p6: 'd,e',
|
||||
}
|
||||
|
||||
const tier1Path = computed(() => {
|
||||
const c = cp.value
|
||||
return `M ${cx(c.p1.x)} ${cy(c.p1.y)} C ${cx(c.p2.x)} ${cy(c.p2.y)}, ${cx(c.p3.x)} ${cy(c.p3.y)}, ${cx(c.p4.x)} ${cy(c.p4.y)}`
|
||||
})
|
||||
const tier2Path = computed(() => {
|
||||
const c = cp.value
|
||||
return `M ${cx(c.p4.x)} ${cy(c.p4.y)} C ${cx(c.p5.x)} ${cy(c.p5.y)}, ${cx(c.p6.x)} ${cy(c.p6.y)}, ${cx(c.p7.x)} ${cy(c.p7.y)}`
|
||||
})
|
||||
|
||||
// ── Drag handling ──
|
||||
const svgRef = ref<SVGSVGElement | null>(null)
|
||||
const dragging = ref<string | null>(null)
|
||||
|
||||
function getSvgPt(event: MouseEvent | Touch) {
|
||||
if (!svgRef.value) return { x: 0, y: 0 }
|
||||
const rect = svgRef.value.getBoundingClientRect()
|
||||
return {
|
||||
x: (event.clientX - rect.left) * (W / rect.width),
|
||||
y: (event.clientY - rect.top) * (H / rect.height),
|
||||
}
|
||||
}
|
||||
function startDrag(key: string) { dragging.value = key }
|
||||
function onMouseMove(e: MouseEvent) { if (dragging.value) handleDrag(getSvgPt(e)) }
|
||||
function onTouchMove(e: TouchEvent) { if (dragging.value && e.touches[0]) handleDrag(getSvgPt(e.touches[0])) }
|
||||
function onMouseUp() {
|
||||
if (dragging.value) {
|
||||
dragging.value = null
|
||||
debouncedServerCompute()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrag(pt: { x: number; y: number }) {
|
||||
const v = Math.max(0, Math.min(vmax.value, fromX(pt.x)))
|
||||
const p = Math.max(0, Math.min(pmax.value, fromY(pt.y)))
|
||||
|
||||
switch (dragging.value) {
|
||||
case 'p2':
|
||||
bp.a = localP0.value > 0 ? Math.max(0, Math.min(1, p / localP0.value)) : 0.5
|
||||
break
|
||||
case 'p3':
|
||||
bp.b = bp.vinf > 0 ? Math.max(0, Math.min(1, v / bp.vinf)) : 0.5
|
||||
break
|
||||
case 'p4':
|
||||
bp.vinf = Math.max(1, Math.min(vmax.value - 1, v))
|
||||
break
|
||||
case 'p5': {
|
||||
const wmax = vmax.value - bp.vinf
|
||||
bp.c = wmax > 0 ? Math.max(0, Math.min(1, (v - bp.vinf) / wmax)) : 0.5
|
||||
break
|
||||
}
|
||||
case 'p6': {
|
||||
const wmax = vmax.value - bp.vinf
|
||||
const qmax = pmax.value - localP0.value
|
||||
bp.e = qmax > 0 ? Math.max(0, Math.min(1, (p - localP0.value) / qmax)) : 0.5
|
||||
if (wmax > 0 && Math.abs(1 - bp.c) > 1e-10) {
|
||||
const ratio = (v - bp.vinf) / wmax
|
||||
bp.d = Math.max(0, Math.min(1, (1 - ratio) / (1 - bp.c)))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
recalculate()
|
||||
}
|
||||
|
||||
function recalculate() {
|
||||
if (!households.value.length) return
|
||||
localP0.value = computeP0(
|
||||
households.value, recettes.value, abop.value, abos.value,
|
||||
bp.vinf, vmax.value, pmax.value, bp.a, bp.b, bp.c, bp.d, bp.e,
|
||||
)
|
||||
const result = computeImpacts(
|
||||
households.value, recettes.value, abop.value, abos.value,
|
||||
bp.vinf, vmax.value, pmax.value, bp.a, bp.b, bp.c, bp.d, bp.e,
|
||||
)
|
||||
impacts.value = result.impacts.map(imp => ({
|
||||
volume: imp.volume,
|
||||
old_price: imp.oldPrice,
|
||||
new_price_rp: imp.newPriceRP,
|
||||
new_price_rs: imp.newPriceRS,
|
||||
}))
|
||||
}
|
||||
|
||||
let serverTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
function debouncedServerCompute() {
|
||||
if (serverTimeout) clearTimeout(serverTimeout)
|
||||
serverTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const result = await api.post<any>('/tariff/compute', {
|
||||
commune_slug: slug, vinf: bp.vinf,
|
||||
a: bp.a, b: bp.b, c: bp.c, d: bp.d, e: bp.e,
|
||||
})
|
||||
localP0.value = result.p0
|
||||
impacts.value = result.impacts.map((imp: any) => ({
|
||||
volume: imp.volume,
|
||||
old_price: imp.old_price,
|
||||
new_price_rp: imp.new_price_rp,
|
||||
new_price_rs: imp.new_price_rs,
|
||||
}))
|
||||
} catch {}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// ── Chart 2: Baseline linear model ──
|
||||
const W2 = 300
|
||||
const H2 = 220
|
||||
const margin2 = { top: 10, right: 10, bottom: 24, left: 40 }
|
||||
const plotW2 = W2 - margin2.left - margin2.right
|
||||
const plotH2 = H2 - margin2.top - margin2.bottom
|
||||
|
||||
function cx2(v: number) { return margin2.left + (v / vmax.value) * plotW2 }
|
||||
|
||||
const maxBill = computed(() => {
|
||||
if (!curveData.value?.baseline_bills_rp?.length) return 500
|
||||
const mx = Math.max(...curveData.value.baseline_bills_rp)
|
||||
return Math.ceil(mx * 1.1 / 100) * 100
|
||||
})
|
||||
|
||||
function cy2bill(b: number) { return margin2.top + plotH2 - (b / maxBill.value) * plotH2 }
|
||||
function cy2price(p: number) { return margin2.top + plotH2 - (p / pmax.value) * plotH2 }
|
||||
|
||||
const gridVols2 = computed(() => {
|
||||
const step = Math.ceil(vmax.value / 5 / 100) * 100
|
||||
const arr: number[] = []
|
||||
for (let v = step; v < vmax.value; v += step) arr.push(v)
|
||||
return arr
|
||||
})
|
||||
const gridBills = computed(() => {
|
||||
const step = Math.ceil(maxBill.value / 4 / 100) * 100
|
||||
const arr: number[] = []
|
||||
for (let b = step; b < maxBill.value; b += step) arr.push(b)
|
||||
return arr
|
||||
})
|
||||
const gridPrices2 = computed(() => {
|
||||
const step = Math.ceil(pmax.value / 4)
|
||||
const arr: number[] = []
|
||||
for (let p = step; p <= pmax.value; p += step) arr.push(p)
|
||||
return arr
|
||||
})
|
||||
|
||||
function toPolyline(vols: number[], vals: number[], cyFn: (v: number) => number) {
|
||||
if (!vols?.length) return ''
|
||||
// Downsample for performance (every 4th point)
|
||||
return vols
|
||||
.filter((_: number, i: number) => i % 4 === 0 || i === vols.length - 1)
|
||||
.map((_: number, i: number) => {
|
||||
const idx = i * 4 >= vols.length ? vols.length - 1 : i * 4
|
||||
return `${cx2(vols[idx])},${cyFn(vals[idx])}`
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const baselineBillRP = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_bills_rp, cy2bill))
|
||||
const baselineBillRS = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_bills_rs, cy2bill))
|
||||
const baselinePriceRP = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_price_m3_rp, cy2price))
|
||||
const baselinePriceRS = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_price_m3_rs, cy2price))
|
||||
|
||||
// ── Auth & vote ──
|
||||
async function authenticate() {
|
||||
authError.value = ''
|
||||
authLoading.value = true
|
||||
try {
|
||||
const data = await api.post<{ access_token: string; role: string; commune_slug: string }>(
|
||||
'/auth/citizen/verify',
|
||||
{ commune_slug: slug, auth_code: authCode.value.toUpperCase() },
|
||||
)
|
||||
authStore.setAuth(data.access_token, data.role, data.commune_slug)
|
||||
} catch (e: any) {
|
||||
authError.value = e.message || 'Code invalide'
|
||||
} finally {
|
||||
authLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitVote() {
|
||||
submitting.value = true
|
||||
voteSuccess.value = false
|
||||
try {
|
||||
await api.post(`/communes/${slug}/votes`, {
|
||||
vinf: bp.vinf, a: bp.a, b: bp.b, c: bp.c, d: bp.d, e: bp.e,
|
||||
})
|
||||
voteSuccess.value = true
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Erreur lors de la soumission')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load data ──
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [c, p, curve, pages] = await Promise.all([
|
||||
api.get<any>(`/communes/${slug}`),
|
||||
api.get<any>(`/communes/${slug}/params`),
|
||||
api.get<any>(`/communes/${slug}/votes/current`),
|
||||
api.get<any[]>(`/communes/${slug}/content`).catch(() => []),
|
||||
])
|
||||
contentPages.value = pages
|
||||
commune.value = c
|
||||
params.value = p
|
||||
curveData.value = curve
|
||||
|
||||
// Set tariff params
|
||||
vmax.value = p.vmax
|
||||
pmax.value = p.pmax
|
||||
recettes.value = p.recettes
|
||||
abop.value = p.abop
|
||||
abos.value = p.abos
|
||||
|
||||
// Set initial Bezier params from median (or default)
|
||||
if (curve.median) {
|
||||
bp.vinf = curve.median.vinf
|
||||
bp.a = curve.median.a
|
||||
bp.b = curve.median.b
|
||||
bp.c = curve.median.c
|
||||
bp.d = curve.median.d
|
||||
bp.e = curve.median.e
|
||||
}
|
||||
localP0.value = curve.p0
|
||||
|
||||
// Set impacts from server
|
||||
impacts.value = curve.impacts || []
|
||||
|
||||
// Build simplified household list for client-side compute
|
||||
const stats = await api.get<any>(`/communes/${slug}/households/stats`)
|
||||
const hh: HouseholdData[] = []
|
||||
const avgVol = stats.avg_volume || 90
|
||||
for (let i = 0; i < (stats.rs_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'RS' })
|
||||
for (let i = 0; i < (stats.rp_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'RP' })
|
||||
for (let i = 0; i < (stats.pro_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
|
||||
households.value = hh
|
||||
} catch (e: any) {
|
||||
loadError.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function renderMarkdown(md: string): string {
|
||||
if (!md) return ''
|
||||
return md
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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(/\n\n/g, '</p><p>')
|
||||
.replace(/^(?!<[hulo])(.+)$/gm, '<p>$1</p>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.editor-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.chart-container svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.side-panel .card {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.param-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.25rem 1rem;
|
||||
}
|
||||
.param-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
.param-label { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
.param-val { font-family: monospace; font-weight: 600; font-size: 0.85rem; }
|
||||
|
||||
.table-sm { font-size: 0.8rem; }
|
||||
.table-sm th, .table-sm td { padding: 0.25rem 0.5rem; }
|
||||
|
||||
.text-up { color: #dc2626; }
|
||||
.text-down { color: #059669; }
|
||||
|
||||
.baseline-charts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.baseline-charts { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.grid-5-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.grid-5-info { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
.info-label { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
|
||||
.alert-success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cms-body { line-height: 1.7; font-size: 0.9rem; }
|
||||
.cms-body :deep(h2) { font-size: 1.2rem; margin: 0.75rem 0 0.5rem; }
|
||||
.cms-body :deep(h3) { font-size: 1.05rem; margin: 0.5rem 0 0.25rem; }
|
||||
.cms-body :deep(p) { margin: 0.5rem 0; }
|
||||
.cms-body :deep(a) { color: var(--color-primary); }
|
||||
.cms-body :deep(ul) { margin: 0.5rem 0; padding-left: 1.5rem; }
|
||||
</style>
|
||||
24
frontend/app/pages/commune/[slug]/vote.vue
Normal file
24
frontend/app/pages/commune/[slug]/vote.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div style="max-width: 600px; margin: 2rem auto; text-align: center;">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom: 1rem;">Vote enregistré</h2>
|
||||
<p style="margin-bottom: 1.5rem;">
|
||||
Votre vote a été soumis avec succès. Vous pouvez revenir à l'éditeur pour modifier votre choix
|
||||
à tout moment (seul votre dernier vote sera pris en compte).
|
||||
</p>
|
||||
<div style="display: flex; gap: 1rem; justify-content: center;">
|
||||
<NuxtLink :to="`/commune/${slug}`" class="btn btn-primary">
|
||||
Modifier mon vote
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/" class="btn btn-secondary">
|
||||
Retour à l'accueil
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
</script>
|
||||
119
frontend/app/pages/index.vue
Normal file
119
frontend/app/pages/index.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<h1>Tarification participative de l'eau</h1>
|
||||
<p>
|
||||
Dessinez votre courbe de tarification idéale et participez aux choix de votre commune.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Communes publiques -->
|
||||
<section>
|
||||
<h2 style="margin-bottom: 1rem;">Communes participantes</h2>
|
||||
|
||||
<div v-if="loading" style="text-align: center; padding: 2rem;">
|
||||
<div class="spinner" style="margin: 0 auto;"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="communes.length === 0" class="alert alert-info">
|
||||
Aucune commune active pour le moment.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-3">
|
||||
<NuxtLink
|
||||
v-for="commune in communes"
|
||||
:key="commune.id"
|
||||
:to="`/commune/${commune.slug}`"
|
||||
class="card commune-card"
|
||||
>
|
||||
<h3>{{ commune.name }}</h3>
|
||||
<p>{{ commune.description }}</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Accès administration -->
|
||||
<section style="margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--color-border);">
|
||||
<div class="grid grid-2">
|
||||
<div class="card">
|
||||
<h3>Espace commune</h3>
|
||||
<p style="margin: 0.75rem 0; color: var(--color-text-muted); font-size: 0.875rem;">
|
||||
Vous êtes responsable d'une commune ? Connectez-vous pour gérer vos données,
|
||||
paramétrer la tarification et consulter les votes.
|
||||
</p>
|
||||
<NuxtLink to="/login/commune" class="btn btn-secondary">Connexion commune</NuxtLink>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Super administration</h3>
|
||||
<p style="margin: 0.75rem 0; color: var(--color-text-muted); font-size: 0.875rem;">
|
||||
Gestion globale des communes et des administrateurs.
|
||||
</p>
|
||||
<NuxtLink to="/login/admin" class="btn btn-secondary">Connexion admin</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const api = useApi()
|
||||
|
||||
const communes = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
communes.value = await api.get<any[]>('/communes/')
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 2rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.1rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.commune-card {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.commune-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.commune-card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.commune-card p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
46
frontend/app/pages/login.vue
Normal file
46
frontend/app/pages/login.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div style="max-width: 500px; margin: 2rem auto;">
|
||||
<div class="page-header" style="text-align: center;">
|
||||
<h1>Connexion</h1>
|
||||
<p style="color: var(--color-text-muted);">Choisissez votre espace.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2">
|
||||
<NuxtLink to="/login/commune" class="card login-choice">
|
||||
<h3>Commune</h3>
|
||||
<p>Gérer les données et la tarification de votre commune.</p>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/login/admin" class="card login-choice">
|
||||
<h3>Super Admin</h3>
|
||||
<p>Gestion globale des communes et administrateurs.</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin-top: 1.5rem;">
|
||||
<NuxtLink to="/">← Retour à l'accueil</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-choice {
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.login-choice:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-choice h3 {
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-choice p {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
63
frontend/app/pages/login/admin.vue
Normal file
63
frontend/app/pages/login/admin.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div style="max-width: 420px; margin: 2rem auto;">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom: 0.5rem;">Super administration</h2>
|
||||
<p style="color: var(--color-text-muted); margin-bottom: 1.5rem; font-size: 0.875rem;">
|
||||
Gestion globale : création de communes, gestion des administrateurs.
|
||||
</p>
|
||||
|
||||
<div v-if="error" class="alert alert-error">{{ error }}</div>
|
||||
|
||||
<form @submit.prevent="login">
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input v-model="email" type="email" class="form-input" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Mot de passe</label>
|
||||
<input v-model="password" type="password" class="form-input" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading" style="width: 100%;">
|
||||
<span v-if="loading" class="spinner" style="width: 1rem; height: 1rem;"></span>
|
||||
<span v-else>Se connecter</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin-top: 1rem;">
|
||||
<NuxtLink to="/">← Retour à l'accueil</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function login() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.post<{ access_token: string; role: string }>('/auth/admin/login', {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
})
|
||||
if (data.role !== 'super_admin') {
|
||||
error.value = 'Ce compte n\'a pas les droits super admin. Utilisez la connexion commune.'
|
||||
return
|
||||
}
|
||||
authStore.setAuth(data.access_token, data.role)
|
||||
router.push('/admin')
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Erreur de connexion'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
64
frontend/app/pages/login/commune.vue
Normal file
64
frontend/app/pages/login/commune.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div style="max-width: 420px; margin: 2rem auto;">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom: 0.5rem;">Espace commune</h2>
|
||||
<p style="color: var(--color-text-muted); margin-bottom: 1.5rem; font-size: 0.875rem;">
|
||||
Connectez-vous pour gérer les données de votre commune.
|
||||
</p>
|
||||
|
||||
<div v-if="error" class="alert alert-error">{{ error }}</div>
|
||||
|
||||
<form @submit.prevent="login">
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input v-model="email" type="email" class="form-input" placeholder="contact@mairie.fr" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Mot de passe</label>
|
||||
<input v-model="password" type="password" class="form-input" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading" style="width: 100%;">
|
||||
<span v-if="loading" class="spinner" style="width: 1rem; height: 1rem;"></span>
|
||||
<span v-else>Se connecter</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin-top: 1rem;">
|
||||
<NuxtLink to="/">← Retour à l'accueil</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const api = useApi()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function login() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.post<{ access_token: string; role: string; commune_slug: string | null }>(
|
||||
'/auth/admin/login',
|
||||
{ email: email.value, password: password.value },
|
||||
)
|
||||
authStore.setAuth(data.access_token, data.role, data.commune_slug || undefined)
|
||||
|
||||
if (data.commune_slug) {
|
||||
router.push(`/admin/communes/${data.commune_slug}`)
|
||||
} else {
|
||||
router.push('/admin')
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Erreur de connexion'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user