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