Initial commit: SejeteralO water tarification platform

Full-stack app for participatory water pricing using Bezier curves.
- Backend: FastAPI + SQLAlchemy + SQLite with JWT auth
- Frontend: Nuxt 4 + TypeScript with interactive SVG editor
- Math engine: cubic Bezier tarification with Cardano solver
- Admin: commune management, household import, vote monitoring, CMS
- Citizen: interactive curve editor, vote submission
- Docker-compose deployment ready

Includes fixes for:
- Impact table snake_case/camelCase property mismatch
- CMS content backend API + frontend editor (was stub)
- Admin route protection middleware
- Public content display on commune page
- Vote confirmation page link fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-21 15:26:02 +01:00
commit b30e54a8f7
67 changed files with 16723 additions and 0 deletions

13
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

13
frontend/app/app.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
const authStore = useAuthStore()
onMounted(() => {
authStore.restore()
})
</script>

View File

@@ -0,0 +1,230 @@
/* SejeteralO - Global Styles */
:root {
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-secondary: #059669;
--color-accent: #d97706;
--color-danger: #dc2626;
--color-bg: #f8fafc;
--color-surface: #ffffff;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-border: #e2e8f0;
--radius: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Layout */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
font-weight: 700;
}
/* Cards */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
background: var(--color-primary-dark);
}
.btn-secondary {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background: var(--color-bg);
}
.btn-danger {
background: var(--color-danger);
color: white;
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
}
.form-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.875rem;
transition: border-color 0.15s;
}
.form-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Tables */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.table th {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-text-muted);
}
/* Grid */
.grid {
display: grid;
gap: 1.5rem;
}
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 {
grid-template-columns: 1fr;
}
}
/* Alerts */
.alert {
padding: 0.75rem 1rem;
border-radius: var(--radius);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.alert-info {
background: #eff6ff;
color: #1e40af;
border: 1px solid #bfdbfe;
}
.alert-success {
background: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
.alert-error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
/* Badge */
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-green {
background: #dcfce7;
color: #166534;
}
.badge-blue {
background: #dbeafe;
color: #1e40af;
}
.badge-amber {
background: #fef3c7;
color: #92400e;
}
/* Loading spinner */
.spinner {
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,518 @@
<template>
<div class="bezier-editor">
<div class="editor-layout">
<!-- SVG Canvas -->
<div class="editor-canvas card">
<svg
ref="svgRef"
:viewBox="`0 0 ${svgW} ${svgH}`"
preserveAspectRatio="xMidYMid meet"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
@touchmove.prevent="onTouchMove"
@touchend="onMouseUp"
>
<!-- Grid -->
<g class="grid-lines">
<line
v-for="v in gridVolumes"
:key="'gv' + v"
:x1="toSvgX(v)" :y1="toSvgY(0)" :x2="toSvgX(v)" :y2="toSvgY(pmax)"
stroke="#e2e8f0" stroke-width="0.5"
/>
<line
v-for="p in gridPrices"
:key="'gp' + p"
:x1="toSvgX(0)" :y1="toSvgY(p)" :x2="toSvgX(vmax)" :y2="toSvgY(p)"
stroke="#e2e8f0" stroke-width="0.5"
/>
<!-- Axis labels -->
<text
v-for="v in gridVolumes"
:key="'lv' + v"
:x="toSvgX(v)" :y="svgH - 2"
text-anchor="middle" font-size="10" fill="#94a3b8"
>{{ v }}</text>
<text
v-for="p in gridPrices"
:key="'lp' + p"
:x="4" :y="toSvgY(p) + 3"
font-size="10" fill="#94a3b8"
>{{ p }}</text>
</g>
<!-- Control point tangent lines -->
<g class="tangent-lines">
<line :x1="toSvgX(cp.p1.x)" :y1="toSvgY(cp.p1.y)" :x2="toSvgX(cp.p2.x)" :y2="toSvgY(cp.p2.y)"
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
<line :x1="toSvgX(cp.p3.x)" :y1="toSvgY(cp.p3.y)" :x2="toSvgX(cp.p4.x)" :y2="toSvgY(cp.p4.y)"
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
<line :x1="toSvgX(cp.p4.x)" :y1="toSvgY(cp.p4.y)" :x2="toSvgX(cp.p5.x)" :y2="toSvgY(cp.p5.y)"
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
<line :x1="toSvgX(cp.p6.x)" :y1="toSvgY(cp.p6.y)" :x2="toSvgX(cp.p7.x)" :y2="toSvgY(cp.p7.y)"
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
</g>
<!-- Bézier curves -->
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="2.5" />
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2.5" />
<!-- Inflection point lines -->
<line :x1="toSvgX(params.vinf)" :y1="toSvgY(0)" :x2="toSvgX(params.vinf)" :y2="toSvgY(localP0)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
<line :x1="toSvgX(0)" :y1="toSvgY(localP0)" :x2="toSvgX(params.vinf)" :y2="toSvgY(localP0)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
<!-- p0 label -->
<text :x="toSvgX(0) + 25" :y="toSvgY(localP0) - 6" font-size="12" fill="#1e293b" font-weight="600">
p₀ = {{ localP0.toFixed(2) }} /
</text>
<!-- Draggable control points -->
<circle
v-for="(point, key) in draggablePoints"
:key="key"
:cx="toSvgX(point.x)"
:cy="toSvgY(point.y)"
:r="dragging === key ? 8 : 6"
:fill="pointColors[key]"
stroke="white"
stroke-width="2"
style="cursor: grab;"
@mousedown.prevent="startDrag(key, $event)"
@touchstart.prevent="startDragTouch(key, $event)"
/>
<!-- Point labels -->
<text
v-for="(point, key) in draggablePoints"
:key="'label-' + key"
:x="toSvgX(point.x) + 10"
:y="toSvgY(point.y) - 10"
font-size="11"
:fill="pointColors[key]"
font-weight="500"
>{{ pointLabels[key] }}</text>
</svg>
</div>
<!-- Right panel -->
<div class="editor-panel">
<!-- Parameters display -->
<div class="card" style="margin-bottom: 1rem;">
<h3 style="margin-bottom: 0.75rem;">Paramètres</h3>
<div class="param-grid">
<div class="param-item">
<span class="param-label">vinf</span>
<span class="param-value">{{ params.vinf.toFixed(0) }} </span>
</div>
<div class="param-item">
<span class="param-label">a</span>
<span class="param-value">{{ params.a.toFixed(3) }}</span>
</div>
<div class="param-item">
<span class="param-label">b</span>
<span class="param-value">{{ params.b.toFixed(3) }}</span>
</div>
<div class="param-item">
<span class="param-label">c</span>
<span class="param-value">{{ params.c.toFixed(3) }}</span>
</div>
<div class="param-item">
<span class="param-label">d</span>
<span class="param-value">{{ params.d.toFixed(3) }}</span>
</div>
<div class="param-item">
<span class="param-label">e</span>
<span class="param-value">{{ params.e.toFixed(3) }}</span>
</div>
<div class="param-item" style="grid-column: span 2;">
<span class="param-label">p₀ (prix inflexion)</span>
<span class="param-value" style="font-size: 1.25rem;">{{ localP0.toFixed(2) }} /</span>
</div>
</div>
</div>
<!-- Impact table -->
<div class="card" style="margin-bottom: 1rem;">
<h3 style="margin-bottom: 0.75rem;">Impact par volume</h3>
<table class="table">
<thead>
<tr>
<th>Volume</th>
<th>Ancien prix</th>
<th>Nouveau (RP)</th>
<th>Nouveau (RS)</th>
</tr>
</thead>
<tbody>
<tr v-for="imp in impacts" :key="imp.volume">
<td>{{ imp.volume }} </td>
<td>{{ imp.oldPrice.toFixed(2) }} </td>
<td :class="imp.newPriceRP > imp.oldPrice ? 'text-up' : 'text-down'">
{{ imp.newPriceRP.toFixed(2) }}
</td>
<td :class="imp.newPriceRS > imp.oldPrice ? 'text-up' : 'text-down'">
{{ imp.newPriceRS.toFixed(2) }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Submit vote -->
<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>
</div>
</div>
</template>
<script setup lang="ts">
import {
computeP0, generateCurve, computeImpacts,
paramsToControlPoints, controlPointsToParams,
type HouseholdData, type ImpactRow, type ControlPoints,
} from '~/utils/bezier-math'
const props = defineProps<{ communeSlug: string }>()
const emit = defineEmits<{ 'vote-submitted': [] }>()
const api = useApi()
// SVG dimensions
const svgW = 600
const svgH = 400
const margin = { top: 20, right: 20, bottom: 30, left: 35 }
const plotW = svgW - margin.left - margin.right
const plotH = svgH - margin.top - margin.bottom
// Commune tariff params (fixed by admin)
const vmax = ref(2100)
const pmax = ref(20)
const recettes = ref(75000)
const abop = ref(100)
const abos = ref(100)
const households = ref<HouseholdData[]>([])
// Citizen-adjustable params
const params = reactive({
vinf: 1050,
a: 0.5,
b: 0.5,
c: 0.5,
d: 0.5,
e: 0.5,
})
// Computed
const localP0 = ref(0)
const impacts = ref<ImpactRow[]>([])
const submitting = ref(false)
const cp = computed<ControlPoints>(() =>
paramsToControlPoints(params.vinf, vmax.value, pmax.value, localP0.value, params.a, params.b, params.c, params.d, params.e)
)
const draggablePoints = computed(() => ({
p2: cp.value.p2,
p3: cp.value.p3,
p4: cp.value.p4,
p5: cp.value.p5,
p6: cp.value.p6,
}))
const pointColors: Record<string, string> = {
p2: '#3b82f6',
p3: '#3b82f6',
p4: '#8b5cf6',
p5: '#ef4444',
p6: '#ef4444',
}
const pointLabels: Record<string, string> = {
p2: 'P₂ (a)',
p3: 'P₃ (b)',
p4: 'P₄ (vinf)',
p5: 'P₅ (c)',
p6: 'P₆ (d,e)',
}
// Grid
const gridVolumes = computed(() => {
const step = Math.ceil(vmax.value / 7 / 100) * 100
const arr = []
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 = []
for (let p = step; p < pmax.value; p += step) arr.push(p)
return arr
})
// Coordinate transforms
function toSvgX(v: number): number {
return margin.left + (v / vmax.value) * plotW
}
function toSvgY(p: number): number {
return margin.top + plotH - (p / pmax.value) * plotH
}
function fromSvgX(sx: number): number {
return ((sx - margin.left) / plotW) * vmax.value
}
function fromSvgY(sy: number): number {
return ((margin.top + plotH - sy) / plotH) * pmax.value
}
// Bézier path generation
const tier1Path = computed(() => {
const c = cp.value
return `M ${toSvgX(c.p1.x)} ${toSvgY(c.p1.y)} C ${toSvgX(c.p2.x)} ${toSvgY(c.p2.y)}, ${toSvgX(c.p3.x)} ${toSvgY(c.p3.y)}, ${toSvgX(c.p4.x)} ${toSvgY(c.p4.y)}`
})
const tier2Path = computed(() => {
const c = cp.value
return `M ${toSvgX(c.p4.x)} ${toSvgY(c.p4.y)} C ${toSvgX(c.p5.x)} ${toSvgY(c.p5.y)}, ${toSvgX(c.p6.x)} ${toSvgY(c.p6.y)}, ${toSvgX(c.p7.x)} ${toSvgY(c.p7.y)}`
})
// Drag handling
const svgRef = ref<SVGSVGElement | null>(null)
const dragging = ref<string | null>(null)
function getSvgPoint(event: MouseEvent | Touch): { x: number; y: number } {
if (!svgRef.value) return { x: 0, y: 0 }
const rect = svgRef.value.getBoundingClientRect()
const scaleX = svgW / rect.width
const scaleY = svgH / rect.height
return {
x: (event.clientX - rect.left) * scaleX,
y: (event.clientY - rect.top) * scaleY,
}
}
function startDrag(key: string, event: MouseEvent) {
dragging.value = key
}
function startDragTouch(key: string, event: TouchEvent) {
dragging.value = key
}
function onMouseMove(event: MouseEvent) {
if (!dragging.value) return
handleDrag(getSvgPoint(event))
}
function onTouchMove(event: TouchEvent) {
if (!dragging.value || !event.touches[0]) return
handleDrag(getSvgPoint(event.touches[0]))
}
function handleDrag(svgPoint: { x: number; y: number }) {
const v = Math.max(0, Math.min(vmax.value, fromSvgX(svgPoint.x)))
const p = Math.max(0, Math.min(pmax.value, fromSvgY(svgPoint.y)))
switch (dragging.value) {
case 'p2': // vertical only → a
params.a = localP0.value > 0 ? Math.max(0, Math.min(1, p / localP0.value)) : 0.5
break
case 'p3': // horizontal only → b
params.b = params.vinf > 0 ? Math.max(0, Math.min(1, v / params.vinf)) : 0.5
break
case 'p4': // horizontal → vinf
params.vinf = Math.max(1, Math.min(vmax.value - 1, v))
break
case 'p5': { // horizontal only → c
const wmax = vmax.value - params.vinf
params.c = wmax > 0 ? Math.max(0, Math.min(1, (v - params.vinf) / wmax)) : 0.5
break
}
case 'p6': { // 2D → d, e
const wmax = vmax.value - params.vinf
const qmax = pmax.value - localP0.value
// e from y
params.e = qmax > 0 ? Math.max(0, Math.min(1, (p - localP0.value) / qmax)) : 0.5
// d from x: x6 = vinf + wmax*(1-d+cd) => d = (1 - (x6-vinf)/wmax)/(1-c)
if (wmax > 0 && Math.abs(1 - params.c) > 1e-10) {
const ratio = (v - params.vinf) / wmax
params.d = Math.max(0, Math.min(1, (1 - ratio) / (1 - params.c)))
}
break
}
}
recalculate()
}
function onMouseUp() {
if (dragging.value) {
dragging.value = null
debouncedServerCompute()
}
}
// Recalculate locally
function recalculate() {
if (households.value.length === 0) return
localP0.value = computeP0(
households.value, recettes.value, abop.value, abos.value,
params.vinf, vmax.value, pmax.value,
params.a, params.b, params.c, params.d, params.e,
)
const result = computeImpacts(
households.value, recettes.value, abop.value, abos.value,
params.vinf, vmax.value, pmax.value,
params.a, params.b, params.c, params.d, params.e,
)
impacts.value = result.impacts
}
// Debounced server compute
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: props.communeSlug,
vinf: params.vinf,
a: params.a,
b: params.b,
c: params.c,
d: params.d,
e: params.e,
})
// Use authoritative server p0
localP0.value = result.p0
impacts.value = result.impacts.map((imp: any) => ({
volume: imp.volume,
oldPrice: imp.old_price,
newPriceRP: imp.new_price_rp,
newPriceRS: imp.new_price_rs,
}))
} catch (e) {
// Silently fall back to client-side calculation
}
}, 300)
}
// Submit vote
async function submitVote() {
submitting.value = true
try {
await api.post(`/communes/${props.communeSlug}/votes`, {
vinf: params.vinf,
a: params.a,
b: params.b,
c: params.c,
d: params.d,
e: params.e,
})
emit('vote-submitted')
} catch (e: any) {
alert(e.message || 'Erreur lors de la soumission du vote')
} finally {
submitting.value = false
}
}
// Load data on mount
onMounted(async () => {
try {
// Load commune params
const communeParams = await api.get<any>(`/communes/${props.communeSlug}/params`)
vmax.value = communeParams.vmax
pmax.value = communeParams.pmax
recettes.value = communeParams.recettes
abop.value = communeParams.abop
abos.value = communeParams.abos
params.vinf = communeParams.vmax / 2
// Load household stats (we need volumes for p0 calculation)
// For client-side compute, we fetch stats and create a simplified model
const stats = await api.get<any>(`/communes/${props.communeSlug}/households/stats`)
// Create representative household distribution for client-side compute
// (simplified: use average volumes by status)
const rsCount = stats.rs_count || 0
const rpCount = stats.rp_count || 0
const proCount = stats.pro_count || 0
const avgVol = stats.avg_volume || 90
const hh: HouseholdData[] = []
for (let i = 0; i < rsCount; i++) hh.push({ volume_m3: avgVol, status: 'RS' })
for (let i = 0; i < rpCount; i++) hh.push({ volume_m3: avgVol, status: 'RP' })
for (let i = 0; i < proCount; i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
households.value = hh
// Initial server compute for accurate p0
recalculate()
debouncedServerCompute()
} catch (e) {
console.error('Error loading commune data:', e)
}
})
</script>
<style scoped>
.editor-layout {
display: grid;
grid-template-columns: 1fr 350px;
gap: 1.5rem;
}
@media (max-width: 900px) {
.editor-layout {
grid-template-columns: 1fr;
}
}
.editor-canvas {
padding: 0.5rem;
}
.editor-canvas svg {
width: 100%;
height: auto;
user-select: none;
}
.param-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.param-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
}
.param-label {
font-size: 0.75rem;
color: var(--color-text-muted);
font-weight: 500;
}
.param-value {
font-family: monospace;
font-weight: 600;
}
.text-up { color: #dc2626; }
.text-down { color: #059669; }
</style>

View File

@@ -0,0 +1,112 @@
<template>
<div class="overlay-chart">
<svg :viewBox="`0 0 ${svgW} ${svgH}`" preserveAspectRatio="xMidYMid meet">
<!-- Grid -->
<g>
<line
v-for="v in gridVolumes"
:key="'gv' + v"
:x1="toSvgX(v)" :y1="toSvgY(0)" :x2="toSvgX(v)" :y2="toSvgY(pmax)"
stroke="#e2e8f0" stroke-width="0.5"
/>
<line
v-for="p in gridPrices"
:key="'gp' + p"
:x1="toSvgX(0)" :y1="toSvgY(p)" :x2="toSvgX(vmax)" :y2="toSvgY(p)"
stroke="#e2e8f0" stroke-width="0.5"
/>
</g>
<!-- Vote curves (semi-transparent) -->
<g v-for="(vote, i) in votes" :key="i">
<path :d="getVotePath(vote, 1)" fill="none" stroke="#3b82f6" stroke-width="1" opacity="0.3" />
<path :d="getVotePath(vote, 2)" fill="none" stroke="#ef4444" stroke-width="1" opacity="0.3" />
</g>
<!-- Median curve (if available) -->
<g v-if="medianVote">
<path :d="getVotePath(medianVote, 1)" fill="none" stroke="#1e40af" stroke-width="3" />
<path :d="getVotePath(medianVote, 2)" fill="none" stroke="#991b1b" stroke-width="3" />
</g>
<!-- Axis labels -->
<text :x="svgW / 2" :y="svgH - 2" text-anchor="middle" font-size="11" fill="#64748b">
Volume ()
</text>
<text :x="6" :y="12" font-size="11" fill="#64748b">/</text>
</svg>
</div>
</template>
<script setup lang="ts">
import { paramsToControlPoints } from '~/utils/bezier-math'
const props = defineProps<{
votes: Array<{ vinf: number; a: number; b: number; c: number; d: number; e: number; computed_p0?: number }>
slug: string
}>()
const api = useApi()
const svgW = 600
const svgH = 300
const margin = { top: 15, right: 15, bottom: 25, left: 30 }
const plotW = svgW - margin.left - margin.right
const plotH = svgH - margin.top - margin.bottom
const vmax = ref(2100)
const pmax = ref(20)
const medianVote = ref<any>(null)
function toSvgX(v: number): number {
return margin.left + (v / vmax.value) * plotW
}
function toSvgY(p: number): number {
return margin.top + plotH - (p / pmax.value) * plotH
}
const gridVolumes = computed(() => {
const step = Math.ceil(vmax.value / 7 / 100) * 100
const arr = []
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 = []
for (let p = step; p < pmax.value; p += step) arr.push(p)
return arr
})
function getVotePath(vote: any, tier: number): string {
const p0 = vote.computed_p0 || 5
const cp = paramsToControlPoints(vote.vinf, vmax.value, pmax.value, p0, vote.a, vote.b, vote.c, vote.d, vote.e)
if (tier === 1) {
return `M ${toSvgX(cp.p1.x)} ${toSvgY(cp.p1.y)} C ${toSvgX(cp.p2.x)} ${toSvgY(cp.p2.y)}, ${toSvgX(cp.p3.x)} ${toSvgY(cp.p3.y)}, ${toSvgX(cp.p4.x)} ${toSvgY(cp.p4.y)}`
} else {
return `M ${toSvgX(cp.p4.x)} ${toSvgY(cp.p4.y)} C ${toSvgX(cp.p5.x)} ${toSvgY(cp.p5.y)}, ${toSvgX(cp.p6.x)} ${toSvgY(cp.p6.y)}, ${toSvgX(cp.p7.x)} ${toSvgY(cp.p7.y)}`
}
}
onMounted(async () => {
try {
const params = await api.get<any>(`/communes/${props.slug}/params`)
vmax.value = params.vmax
pmax.value = params.pmax
} catch {}
try {
medianVote.value = await api.get(`/communes/${props.slug}/votes/median`)
} catch {}
})
</script>
<style scoped>
.overlay-chart svg {
width: 100%;
height: auto;
}
</style>

View File

@@ -0,0 +1,67 @@
/**
* Composable for API calls to the FastAPI backend.
*/
export function useApi() {
const config = useRuntimeConfig()
const baseURL = config.public.apiBase as string
function getToken(): string | null {
if (import.meta.client) {
return localStorage.getItem('sejeteralo_token')
}
return null
}
async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
}
const token = getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
if (options.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
}
let response: Response
try {
response = await fetch(`${baseURL}${path}`, {
...options,
headers,
})
} catch (err) {
throw new Error(`Impossible de contacter le serveur (${baseURL}). Vérifiez que le backend est lancé.`)
}
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }))
throw new Error(error.detail || `Erreur API ${response.status}`)
}
const text = await response.text()
if (!text) return {} as T
return JSON.parse(text)
}
return {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, {
method: 'POST',
body: body instanceof FormData ? body : JSON.stringify(body),
}),
put: <T>(path: string, body?: unknown) =>
apiFetch<T>(path, {
method: 'PUT',
body: JSON.stringify(body),
}),
delete: <T>(path: string) =>
apiFetch<T>(path, { method: 'DELETE' }),
}
}

View File

@@ -0,0 +1,96 @@
<template>
<div class="app-layout">
<header class="app-header">
<div class="container header-inner">
<NuxtLink to="/" class="logo">SejeteralO</NuxtLink>
<nav class="header-nav">
<NuxtLink to="/">Accueil</NuxtLink>
<template v-if="authStore.isAuthenticated">
<NuxtLink v-if="authStore.isSuperAdmin" to="/admin">Super Admin</NuxtLink>
<NuxtLink
v-else-if="authStore.isAdmin && authStore.communeSlug"
:to="`/admin/communes/${authStore.communeSlug}`"
>
Gestion commune
</NuxtLink>
<button class="btn btn-secondary btn-sm" @click="logout">Déconnexion</button>
</template>
</nav>
</div>
</header>
<main class="app-main container">
<slot />
</main>
<footer class="app-footer">
<div class="container">
SejeteralO Outil de démocratie participative pour la tarification de l'eau
</div>
</footer>
</div>
</template>
<script setup lang="ts">
const authStore = useAuthStore()
const router = useRouter()
function logout() {
authStore.logout()
router.push('/')
}
</script>
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 0.75rem 0;
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-primary);
}
.logo:hover {
text-decoration: none;
}
.header-nav {
display: flex;
align-items: center;
gap: 1.5rem;
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
.app-main {
flex: 1;
padding-top: 2rem;
padding-bottom: 2rem;
}
.app-footer {
background: var(--color-surface);
border-top: 1px solid var(--color-border);
padding: 1rem 0;
text-align: center;
font-size: 0.75rem;
color: var(--color-text-muted);
}
</style>

View File

@@ -0,0 +1,11 @@
/**
* Route middleware: redirects to login if user is not an admin.
* Apply via definePageMeta({ middleware: 'admin' }) on admin pages.
*/
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated || !authStore.isAdmin) {
return navigateTo('/login')
}
})

View File

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

View File

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

View File

@@ -0,0 +1,242 @@
<template>
<div v-if="commune">
<div class="page-header">
<div style="display: flex; align-items: center; gap: 1rem;">
<NuxtLink v-if="authStore.isSuperAdmin" to="/admin" style="color: var(--color-text-muted);">&larr; Admin</NuxtLink>
<h1>{{ commune.name }}</h1>
</div>
</div>
<div class="grid grid-2" style="margin-bottom: 2rem;">
<NuxtLink :to="`/admin/communes/${slug}/params`" class="card nav-card">
<h3>Parametres tarifs</h3>
<p class="nav-card-desc">Configurer les recettes, abonnements, prix max...</p>
</NuxtLink>
<NuxtLink :to="`/admin/communes/${slug}/import`" class="card nav-card">
<h3>Import foyers</h3>
<p class="nav-card-desc">Importer les donnees des foyers (CSV/XLSX)</p>
</NuxtLink>
<NuxtLink :to="`/admin/communes/${slug}/votes`" class="card nav-card">
<h3>Votes</h3>
<p class="nav-card-desc">Consulter les votes, la mediane et l'overlay</p>
</NuxtLink>
<NuxtLink :to="`/admin/communes/${slug}/content`" class="card nav-card">
<h3>Contenu CMS</h3>
<p class="nav-card-desc">Editer le contenu de la page commune</p>
</NuxtLink>
</div>
<!-- Stats -->
<div v-if="stats" class="card" style="margin-bottom: 2rem;">
<h3 style="margin-bottom: 1rem;">Statistiques foyers</h3>
<div class="grid grid-4">
<div>
<div style="font-size: 2rem; font-weight: 700;">{{ stats.total }}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Foyers total</div>
</div>
<div>
<div style="font-size: 2rem; font-weight: 700;">{{ stats.voted_count }}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Ont vote</div>
</div>
<div>
<div style="font-size: 2rem; font-weight: 700;">{{ stats.avg_volume?.toFixed(1) }}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Volume moyen (m3)</div>
</div>
<div>
<div style="font-size: 2rem; font-weight: 700;">{{ stats.median_volume?.toFixed(1) }}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Volume median (m3)</div>
</div>
</div>
</div>
<!-- Household codes management -->
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3>Codes foyers</h3>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<input
v-model="search"
type="text"
class="form-input"
placeholder="Rechercher un foyer..."
style="width: 220px; padding: 0.375rem 0.75rem; font-size: 0.875rem;"
/>
</div>
</div>
<div v-if="householdsLoading" style="text-align: center; padding: 1rem;">
<div class="spinner" style="margin: 0 auto;"></div>
</div>
<div v-else-if="households.length === 0" class="alert alert-info">
Aucun foyer importe. Utilisez la page "Import foyers" pour charger les donnees.
</div>
<div v-else>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">
{{ filteredHouseholds.length }} foyer(s) affiche(s) sur {{ households.length }}
</p>
<div class="table-scroll">
<table class="table">
<thead>
<tr>
<th>Identifiant</th>
<th>Statut</th>
<th>Volume (m3)</th>
<th>Code d'acces</th>
<th>A vote</th>
</tr>
</thead>
<tbody>
<tr v-for="h in paginatedHouseholds" :key="h.id">
<td>{{ h.identifier }}</td>
<td>
<span class="badge" :class="statusBadge(h.status)">{{ h.status }}</span>
</td>
<td>{{ h.volume_m3.toFixed(1) }}</td>
<td>
<code class="auth-code">{{ h.auth_code }}</code>
</td>
<td>
<span v-if="h.has_voted" style="color: #059669;">Oui</span>
<span v-else style="color: var(--color-text-muted);">Non</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" style="display: flex; justify-content: center; gap: 0.5rem; margin-top: 1rem;">
<button
class="btn btn-secondary btn-sm"
:disabled="page === 1"
@click="page--"
>&laquo; Prec.</button>
<span style="padding: 0.375rem 0.5rem; font-size: 0.875rem;">
{{ page }} / {{ totalPages }}
</span>
<button
class="btn btn-secondary btn-sm"
:disabled="page >= totalPages"
@click="page++"
>Suiv. &raquo;</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const authStore = useAuthStore()
const api = useApi()
const slug = route.params.slug as string
const commune = ref<any>(null)
const stats = ref<any>(null)
const households = ref<any[]>([])
const householdsLoading = ref(false)
const search = ref('')
const page = ref(1)
const perPage = 20
const filteredHouseholds = computed(() => {
if (!search.value) return households.value
const q = search.value.toLowerCase()
return households.value.filter(h =>
h.identifier.toLowerCase().includes(q) ||
h.auth_code.toLowerCase().includes(q) ||
h.status.toLowerCase().includes(q)
)
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredHouseholds.value.length / perPage)))
const paginatedHouseholds = computed(() => {
const start = (page.value - 1) * perPage
return filteredHouseholds.value.slice(start, start + perPage)
})
watch(search, () => { page.value = 1 })
function statusBadge(status: string) {
if (status === 'RS') return 'badge-amber'
if (status === 'PRO') return 'badge-blue'
return 'badge-green'
}
onMounted(async () => {
try {
commune.value = await api.get<any>(`/communes/${slug}`)
} catch (e: any) {
return
}
// Load stats and households in parallel
householdsLoading.value = true
try {
const [s, hh] = await Promise.all([
api.get<any>(`/communes/${slug}/households/stats`),
api.get<any[]>(`/communes/${slug}/households`),
])
stats.value = s
households.value = hh
} catch (e: any) {
// stats or households may fail if not imported yet
} finally {
householdsLoading.value = false
}
})
</script>
<style scoped>
.nav-card {
cursor: pointer;
transition: box-shadow 0.15s;
}
.nav-card:hover {
box-shadow: var(--shadow-md);
text-decoration: none;
}
.nav-card h3 {
color: var(--color-primary);
margin-bottom: 0.25rem;
}
.nav-card-desc {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.auth-code {
background: var(--color-surface);
border: 1px solid var(--color-border);
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.9rem;
letter-spacing: 0.1em;
user-select: all;
}
.table-scroll {
overflow-x: auto;
}
.badge-blue {
background: #dbeafe;
color: #1e40af;
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
</style>

View File

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

View File

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

View File

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

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

View 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;">&larr; 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/\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>

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

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

View 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="/">&larr; 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>

View 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="/">&larr; 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>

View 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="/">&larr; 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>

View File

@@ -0,0 +1,56 @@
import { defineStore } from 'pinia'
interface AuthState {
token: string | null
role: string | null
communeSlug: string | null
}
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
token: null,
role: null,
communeSlug: null,
}),
getters: {
isAuthenticated: (state) => !!state.token,
isAdmin: (state) => state.role === 'super_admin' || state.role === 'commune_admin',
isSuperAdmin: (state) => state.role === 'super_admin',
isCitizen: (state) => state.role === 'citizen',
},
actions: {
setAuth(token: string, role: string, communeSlug?: string) {
this.token = token
this.role = role
this.communeSlug = communeSlug || null
if (import.meta.client) {
localStorage.setItem('sejeteralo_token', token)
localStorage.setItem('sejeteralo_role', role)
if (communeSlug) localStorage.setItem('sejeteralo_commune', communeSlug)
}
},
logout() {
this.token = null
this.role = null
this.communeSlug = null
if (import.meta.client) {
localStorage.removeItem('sejeteralo_token')
localStorage.removeItem('sejeteralo_role')
localStorage.removeItem('sejeteralo_commune')
}
},
restore() {
if (import.meta.client) {
this.token = localStorage.getItem('sejeteralo_token')
this.role = localStorage.getItem('sejeteralo_role')
this.communeSlug = localStorage.getItem('sejeteralo_commune')
}
},
},
})

View File

@@ -0,0 +1,48 @@
import { defineStore } from 'pinia'
interface Commune {
id: number
name: string
slug: string
description: string
is_active: boolean
}
interface TariffParams {
abop: number
abos: number
recettes: number
pmax: number
vmax: number
}
export const useCommuneStore = defineStore('commune', {
state: () => ({
communes: [] as Commune[],
current: null as Commune | null,
params: null as TariffParams | null,
loading: false,
}),
actions: {
async fetchCommunes() {
this.loading = true
try {
const api = useApi()
this.communes = await api.get<Commune[]>('/communes/')
} finally {
this.loading = false
}
},
async fetchCommune(slug: string) {
const api = useApi()
this.current = await api.get<Commune>(`/communes/${slug}`)
},
async fetchParams(slug: string) {
const api = useApi()
this.params = await api.get<TariffParams>(`/communes/${slug}/params`)
},
},
})

View File

@@ -0,0 +1,368 @@
/**
* TypeScript port of the Bézier tariff math engine.
*
* Mirrors backend/app/engine/integrals.py and pricing.py.
* Uses Cardano's formula + Newton-Raphson polish for cubic solving.
*/
// ── Cubic solver ──
/**
* Solve ax³ + bx² + cx + d = 0 for real roots in [0, 1].
* Uses Cardano's method with Newton-Raphson refinement.
*/
function solveCubicInUnit(a: number, b: number, c: number, d: number): number | null {
if (Math.abs(a) < 1e-12) {
// Degenerate: quadratic
if (Math.abs(b) < 1e-12) {
// Linear
if (Math.abs(c) < 1e-12) return null
const t = -d / c
return t >= -1e-10 && t <= 1 + 1e-10 ? clamp01(t) : null
}
const disc = c * c - 4 * b * d
if (disc < 0) return null
const sqrtDisc = Math.sqrt(disc)
const t1 = (-c + sqrtDisc) / (2 * b)
const t2 = (-c - sqrtDisc) / (2 * b)
if (t1 >= -1e-10 && t1 <= 1 + 1e-10) return clamp01(t1)
if (t2 >= -1e-10 && t2 <= 1 + 1e-10) return clamp01(t2)
return null
}
// Normalize: t³ + pt² + qt + r = 0
const p = b / a
const q = c / a
const r = d / a
// Depressed cubic: u³ + pu + q = 0 via substitution t = u - p/3
const p1 = q - p * p / 3
const q1 = r - p * q / 3 + 2 * p * p * p / 27
const discriminant = q1 * q1 / 4 + p1 * p1 * p1 / 27
const roots: number[] = []
if (discriminant > 1e-12) {
// One real root
const sqrtD = Math.sqrt(discriminant)
const u = cbrt(-q1 / 2 + sqrtD)
const v = cbrt(-q1 / 2 - sqrtD)
roots.push(u + v - p / 3)
} else if (discriminant < -1e-12) {
// Three real roots (casus irreducibilis)
const m = Math.sqrt(-p1 / 3)
const theta = Math.acos((-q1 / 2) / (m * m * m)) / 3
roots.push(
2 * m * Math.cos(theta) - p / 3,
2 * m * Math.cos(theta - 2 * Math.PI / 3) - p / 3,
2 * m * Math.cos(theta - 4 * Math.PI / 3) - p / 3,
)
} else {
// Double or triple root
const u = cbrt(-q1 / 2)
roots.push(2 * u - p / 3, -u - p / 3)
}
// Find root in [0,1] and refine with Newton-Raphson
for (const root of roots) {
if (root >= -0.1 && root <= 1.1) {
let t = clamp01(root)
// Newton-Raphson polish (3 iterations)
for (let i = 0; i < 3; i++) {
const f = ((a * t + b) * t + c) * t + d
const fp = (3 * a * t + 2 * b) * t + c
if (Math.abs(fp) < 1e-14) break
t = clamp01(t - f / fp)
}
return t
}
}
return null
}
function cbrt(x: number): number {
return x < 0 ? -Math.pow(-x, 1 / 3) : Math.pow(x, 1 / 3)
}
function clamp01(t: number): number {
return Math.max(0, Math.min(1, t))
}
// ── Integral computation ──
export interface IntegralResult {
alpha1: number
alpha2: number
beta2: number
}
export function computeIntegrals(
volume: number,
vinf: number,
vmax: number,
pmax: number,
a: number,
b: number,
c: number,
d: number,
e: number,
): IntegralResult {
if (volume <= vinf) {
const T = solveTier1T(volume, vinf, b)
const alpha1 = computeAlpha1(T, vinf, a, b)
return { alpha1, alpha2: 0, beta2: 0 }
} else {
const alpha1 = computeAlpha1(1.0, vinf, a, b)
const wmax = vmax - vinf
const T = solveTier2T(volume - vinf, wmax, c, d)
const uu = computeUU(T, c, d, e)
const alpha2 = (volume - vinf) - 3 * uu * wmax
const beta2 = 3 * pmax * wmax * uu
return { alpha1, alpha2, beta2 }
}
}
function solveTier1T(volume: number, vinf: number, b: number): number {
if (volume <= 0) return 0
if (volume >= vinf) return 1
const ratio = volume / vinf
const t = solveCubicInUnit(1 - 3 * b, 3 * b, 0, -ratio)
return t ?? 0
}
function solveTier2T(w: number, wmax: number, c: number, d: number): number {
if (w <= 0) return 0
if (w >= wmax) return 1
const ratio = w / wmax
const t = solveCubicInUnit(
3 * (c + d - c * d) - 2,
3 * (1 - 2 * c - d + c * d),
3 * c,
-ratio,
)
return t ?? 0
}
function computeAlpha1(T: number, vinf: number, a: number, b: number): number {
return 3 * vinf * (
Math.pow(T, 6) / 6 * (-9 * a * b + 3 * a + 6 * b - 2) +
Math.pow(T, 5) / 5 * (24 * a * b - 6 * a - 13 * b + 3) +
3 * Math.pow(T, 4) / 4 * (-7 * a * b + a + 2 * b) +
Math.pow(T, 3) / 3 * 6 * a * b
)
}
function computeUU(T: number, c: number, d: number, e: number): number {
return (
(-3 * c * d + 9 * e * c * d + 3 * c - 9 * e * c + 3 * d - 9 * e * d + 6 * e - 2) * Math.pow(T, 6) / 6 +
(2 * c * d - 15 * e * c * d - 4 * c + 21 * e * c - 2 * d + 15 * e * d - 12 * e + 2) * Math.pow(T, 5) / 5 +
(6 * e * c * d + c - 15 * e * c - 6 * e * d + 6 * e) * Math.pow(T, 4) / 4 +
(3 * e * c) * Math.pow(T, 3) / 3
)
}
// ── Pricing computation ──
export interface HouseholdData {
volume_m3: number
status: string
}
export interface PricingResult {
p0: number
curveVolumes: number[]
curvePricesM3: number[]
}
export interface ImpactRow {
volume: number
oldPrice: number
newPriceRP: number
newPriceRS: number
}
export function computeP0(
households: HouseholdData[],
recettes: number,
abop: number,
abos: number,
vinf: number,
vmax: number,
pmax: number,
a: number,
b: number,
c: number,
d: number,
e: number,
): number {
let totalAbo = 0
let totalAlpha = 0
let totalBeta = 0
for (const h of households) {
const abo = h.status === 'RS' ? abos : abop
totalAbo += abo
const vol = Math.max(h.volume_m3, 1e-5)
const { alpha1, alpha2, beta2 } = computeIntegrals(vol, vinf, vmax, pmax, a, b, c, d, e)
totalAlpha += alpha1 + alpha2
totalBeta += beta2
}
if (totalAbo >= recettes) return 0
if (totalAlpha === 0) return 0
return (recettes - totalAbo - totalBeta) / totalAlpha
}
/**
* Generate price curve points (price per m³ vs volume).
*/
export function generateCurve(
vinf: number,
vmax: number,
pmax: number,
p0: number,
a: number,
b: number,
c: number,
d: number,
e: number,
nbpts: number = 200,
): PricingResult {
const curveVolumes: number[] = []
const curvePricesM3: number[] = []
const dt = 1 / (nbpts - 1)
// Tier 1
for (let i = 0; i < nbpts; i++) {
const t = Math.min(i * dt, 1 - 1e-6)
const v = vinf * ((1 - 3 * b) * t * t * t + 3 * b * t * t)
const p = p0 * ((3 * a - 2) * t * t * t + (-6 * a + 3) * t * t + 3 * a * t)
curveVolumes.push(v)
curvePricesM3.push(p)
}
// Tier 2
for (let i = 0; i < nbpts; i++) {
const t = Math.min(i * dt, 1 - 1e-6)
const v = vinf + (vmax - vinf) * (
(3 * (c + d - c * d) - 2) * t * t * t +
3 * (1 - 2 * c - d + c * d) * t * t +
3 * c * t
)
const p = p0 + (pmax - p0) * ((1 - 3 * e) * t * t * t + 3 * e * t * t)
curveVolumes.push(v)
curvePricesM3.push(p)
}
return { p0, curveVolumes, curvePricesM3 }
}
/**
* Compute price impacts at reference volume levels.
*/
export function computeImpacts(
households: HouseholdData[],
recettes: number,
abop: number,
abos: number,
vinf: number,
vmax: number,
pmax: number,
a: number,
b: number,
c: number,
d: number,
e: number,
referenceVolumes: number[] = [30, 60, 90, 150, 300],
): { p0: number; impacts: ImpactRow[] } {
const p0 = computeP0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e)
// Linear baseline
const totalVol = households.reduce((s, h) => s + Math.max(h.volume_m3, 1e-5), 0)
const totalAbo = households.reduce((s, h) => s + (h.status === 'RS' ? abos : abop), 0)
const oldPM3 = totalVol > 0 ? (recettes - totalAbo) / totalVol : 0
const impacts: ImpactRow[] = referenceVolumes.map((vol) => {
const { alpha1, alpha2, beta2 } = computeIntegrals(vol, vinf, vmax, pmax, a, b, c, d, e)
return {
volume: vol,
oldPrice: abop + oldPM3 * vol,
newPriceRP: abop + (alpha1 + alpha2) * p0 + beta2,
newPriceRS: abos + (alpha1 + alpha2) * p0 + beta2,
}
})
return { p0, impacts }
}
// ── Control point mapping ──
export interface ControlPoints {
// Tier 1: P1(fixed), P2, P3, P4
p1: { x: number; y: number }
p2: { x: number; y: number }
p3: { x: number; y: number }
p4: { x: number; y: number }
// Tier 2: P4(shared), P5, P6, P7(fixed)
p5: { x: number; y: number }
p6: { x: number; y: number }
p7: { x: number; y: number }
}
export function paramsToControlPoints(
vinf: number,
vmax: number,
pmax: number,
p0: number,
a: number,
b: number,
c: number,
d: number,
e: number,
): ControlPoints {
return {
p1: { x: 0, y: 0 },
p2: { x: 0, y: a * p0 },
p3: { x: b * vinf, y: p0 },
p4: { x: vinf, y: p0 },
p5: { x: vinf + c * (vmax - vinf), y: p0 },
p6: {
x: vinf + (vmax - vinf) * (1 - d + c * d),
y: p0 + e * (pmax - p0),
},
p7: { x: vmax, y: pmax },
}
}
export function controlPointsToParams(
cp: ControlPoints,
vmax: number,
pmax: number,
p0: number,
): { vinf: number; a: number; b: number; c: number; d: number; e: number } {
const vinf = cp.p4.x
const a = p0 > 0 ? clamp01(cp.p2.y / p0) : 0.5
const b = vinf > 0 ? clamp01(cp.p3.x / vinf) : 0.5
const wmax = vmax - vinf
const c = wmax > 0 ? clamp01((cp.p5.x - vinf) / wmax) : 0.5
const qmax = pmax - p0
const e = qmax > 0 ? clamp01((cp.p6.y - p0) / qmax) : 0.5
// d from p6.x: x6 = vinf + wmax * (1 - d + c*d) => d = (1 - (x6-vinf)/wmax) / (1-c)
let d_val = 0.5
if (wmax > 0) {
const ratio = (cp.p6.x - vinf) / wmax
if (Math.abs(1 - c) > 1e-10) {
d_val = clamp01((1 - ratio) / (1 - c))
}
}
return { vinf, a, b, c, d: d_val, e }
}

30
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,30 @@
export default defineNuxtConfig({
compatibilityDate: '2025-01-01',
future: { compatibilityVersion: 4 },
modules: ['@pinia/nuxt'],
devtools: { enabled: true },
devServer: {
port: 3009,
},
runtimeConfig: {
public: {
apiBase: 'http://localhost:8000/api/v1',
},
},
css: ['~/assets/css/main.css'],
app: {
head: {
title: 'SejeteralO - Tarification participative de l\'eau',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
},
},
})

9900
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "sejeteralo-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"generate": "nuxt generate"
},
"dependencies": {
"nuxt": "^4.3.1",
"vue": "^3.5.28",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@pinia/nuxt": "^0.9.0",
"pinia": "^3.0.2",
"typescript": "^5.8.2"
}
}

3
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}