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

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>