Restructure citizen vote page + add vote deadline

Reorganize the citizen page (/commune/[slug]) for voters: full-width
interactive chart with "Population" zoom by default, separate auth and
vote sections, countdown timer for vote deadline.

Backend: add vote_deadline column to communes with Alembic migration.
Admin: add deadline configuration card with datetime-local input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-23 00:46:48 +01:00
parent 2af95ebcf1
commit 6caea1b809
7 changed files with 434 additions and 206 deletions

View File

@@ -0,0 +1,30 @@
"""add vote_deadline to communes
Revision ID: 0d7cc7e3efb9
Revises: 25f534648ea7
Create Date: 2026-02-23 00:37:23.451137
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0d7cc7e3efb9'
down_revision: Union[str, None] = '25f534648ea7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('communes', sa.Column('vote_deadline', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('communes', 'vote_deadline')
# ### end Alembic commands ###

View File

@@ -29,6 +29,7 @@ class Commune(Base):
description = Column(Text, default="")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
vote_deadline = Column(DateTime, nullable=True)
tariff_params = relationship("TariffParams", back_populates="commune", uselist=False)
households = relationship("Household", back_populates="commune")

View File

@@ -67,6 +67,8 @@ async def update_commune(
commune.description = data.description
if data.is_active is not None:
commune.is_active = data.is_active
if data.vote_deadline is not None:
commune.vote_deadline = data.vote_deadline
await db.commit()
await db.refresh(commune)

View File

@@ -35,6 +35,7 @@ class CommuneUpdate(BaseModel):
name: str | None = None
description: str | None = None
is_active: bool | None = None
vote_deadline: datetime | None = None
class CommuneOut(BaseModel):
@@ -44,6 +45,7 @@ class CommuneOut(BaseModel):
description: str
is_active: bool
created_at: datetime
vote_deadline: datetime | None = None
model_config = {"from_attributes": True}

View File

@@ -19,14 +19,14 @@
<!-- 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" />
<path :d="getVotePath(vote, 1)" fill="none" stroke="#3b82f6" stroke-width="1.5" opacity="0.4" />
<path :d="getVotePath(vote, 2)" fill="none" stroke="#ef4444" stroke-width="1.5" opacity="0.4" />
</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" />
<path :d="getVotePath(medianVote, 1)" fill="none" stroke="#1e40af" stroke-width="4" />
<path :d="getVotePath(medianVote, 2)" fill="none" stroke="#991b1b" stroke-width="4" />
</g>
<!-- Axis labels -->
@@ -108,5 +108,13 @@ onMounted(async () => {
.overlay-chart svg {
width: 100%;
height: auto;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.overlay-chart svg path {
stroke-linecap: round;
stroke-linejoin: round;
}
</style>

View File

@@ -29,6 +29,29 @@
</NuxtLink>
</div>
<!-- Vote deadline -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="margin-bottom: 1rem;">Parametres du vote</h3>
<div style="display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap;">
<div>
<label style="display: block; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.25rem;">
Date limite de vote
</label>
<input
v-model="voteDeadline"
type="datetime-local"
class="form-input"
style="width: 260px;"
/>
</div>
<button class="btn btn-primary" @click="saveDeadline" :disabled="savingDeadline">
<span v-if="savingDeadline" class="spinner" style="width: 1rem; height: 1rem;"></span>
Enregistrer
</button>
<span v-if="deadlineSaved" style="color: #059669; font-size: 0.85rem;">Enregistre !</span>
</div>
</div>
<!-- Stats -->
<div v-if="stats" class="card" style="margin-bottom: 2rem;">
<h3 style="margin-bottom: 1rem;">Statistiques foyers</h3>
@@ -145,6 +168,26 @@ const search = ref('')
const page = ref(1)
const perPage = 20
// Vote deadline
const voteDeadline = ref('')
const savingDeadline = ref(false)
const deadlineSaved = ref(false)
async function saveDeadline() {
savingDeadline.value = true
deadlineSaved.value = false
try {
await api.put(`/communes/${slug}`, {
vote_deadline: voteDeadline.value ? new Date(voteDeadline.value).toISOString() : null,
})
deadlineSaved.value = true
} catch (e: any) {
alert(e.message || 'Erreur')
} finally {
savingDeadline.value = false
}
}
const filteredHouseholds = computed(() => {
if (!search.value) return households.value
const q = search.value.toLowerCase()
@@ -173,6 +216,10 @@ function statusBadge(status: string) {
onMounted(async () => {
try {
commune.value = await api.get<any>(`/communes/${slug}`)
if (commune.value.vote_deadline) {
// Format for datetime-local input (YYYY-MM-DDTHH:mm)
voteDeadline.value = commune.value.vote_deadline.slice(0, 16)
}
} catch (e: any) {
return
}

View File

@@ -1,5 +1,6 @@
<template>
<div v-if="commune">
<!-- 1. Header -->
<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>
@@ -13,16 +14,9 @@
</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
2. COURBE INTERACTIVE Position dominante, pleine largeur
-->
<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;">
@@ -45,8 +39,8 @@
<!-- Zoom controls -->
<div class="zoom-bar">
<button class="btn btn-secondary btn-xs" @click="zoomPreset('tier1')" title="Zoom tier 1">Tier 1</button>
<button class="btn btn-secondary btn-xs" @click="zoomPreset('tier2')" title="Zoom tier 2">Tier 2</button>
<button class="btn btn-secondary btn-xs" @click="zoomPreset('tier1')" title="Zoom population">Population</button>
<button class="btn btn-secondary btn-xs" @click="zoomPreset('tier2')" title="Zoom cas exceptionnels">Cas exceptionnels</button>
<button class="btn btn-secondary btn-xs" @click="zoomIn">+</button>
<button class="btn btn-secondary btn-xs" @click="zoomOut">-</button>
<button class="btn btn-secondary btn-xs" @click="zoomReset">Reset</button>
@@ -55,183 +49,236 @@
</span>
</div>
<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"
@wheel.prevent="onWheel"
>
<!-- Grid -->
<g>
<line v-for="v in gridVols" :key="'gv'+v"
:x1="cx(v)" :y1="cy(0)" :x2="cx(v)" :y2="cy(zoomPriceMax)"
stroke="#e2e8f0" stroke-width="0.5" />
<line v-for="p in gridPrices" :key="'gp'+p"
:x1="cx(zoomVolMin)" :y1="cy(p)" :x2="cx(zoomVolMax)" :y2="cy(p)"
stroke="#e2e8f0" stroke-width="0.5" />
<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>
<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.toFixed(p < 1 ? 1 : 0) }}
</text>
<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">
EUR/m3
</text>
</g>
<!-- Outlier vote curves (semi-transparent) -->
<g v-if="showOutliers && outlierVotes.length">
<template v-for="(vote, i) in outlierVotes" :key="'ov'+i">
<path :d="outlierPath(vote, 1)" fill="none" stroke="#93c5fd" stroke-width="1" opacity="0.25" />
<path :d="outlierPath(vote, 2)" fill="none" stroke="#fca5a5" stroke-width="1" opacity="0.25" />
</template>
</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, thicker = focus) -->
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="3" />
<!-- Bezier curve: tier 2 (red) -->
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2" />
<!-- Inflection reference lines -->
<line :x1="cx(bp.vinf)" :y1="cy(zoomPriceMin)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
<line :x1="cx(zoomVolMin)" :y1="cy(localP0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
<!-- p0 label -->
<text :x="cx(zoomVolMin) + 30" :y="cy(localP0) - 6" font-size="12" fill="#1e293b" font-weight="600">
p0 = {{ localP0.toFixed(2) }} EUR/m3
<!-- SVG: full-width, no side panel -->
<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"
@wheel.prevent="onWheel"
>
<!-- Grid -->
<g>
<line v-for="v in gridVols" :key="'gv'+v"
:x1="cx(v)" :y1="cy(0)" :x2="cx(v)" :y2="cy(zoomPriceMax)"
stroke="#e2e8f0" stroke-width="0.5" />
<line v-for="p in gridPrices" :key="'gp'+p"
:x1="cx(zoomVolMin)" :y1="cy(p)" :x2="cx(zoomVolMax)" :y2="cy(p)"
stroke="#e2e8f0" stroke-width="0.5" />
<text v-for="v in gridVols" :key="'lv'+v"
:x="cx(v)" :y="H - 2" text-anchor="middle" font-size="12" fill="#64748b">
{{ v }}
</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)"
/>
<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 v-for="p in gridPrices" :key="'lp'+p"
:x="margin.left - 4" :y="cy(p) + 3" text-anchor="end" font-size="12" fill="#64748b">
{{ p.toFixed(p < 1 ? 1 : 0) }}
</text>
</svg>
<text :x="W / 2" :y="H - 0" text-anchor="middle" font-size="12" fill="#475569">
volume (m3)
</text>
<text :x="12" :y="margin.top - 4" font-size="12" fill="#475569">
EUR/m3
</text>
</g>
<!-- Outlier vote curves (semi-transparent) -->
<g v-if="showOutliers && outlierVotes.length">
<template v-for="(vote, i) in outlierVotes" :key="'ov'+i">
<path :d="outlierPath(vote, 1)" fill="none" stroke="#93c5fd" stroke-width="1" opacity="0.25" />
<path :d="outlierPath(vote, 2)" fill="none" stroke="#fca5a5" stroke-width="1" opacity="0.25" />
</template>
</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: population (blue, thicker = focus) -->
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="3" />
<!-- Bezier curve: cas exceptionnels (red) -->
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2" />
<!-- Inflection reference lines -->
<line :x1="cx(bp.vinf)" :y1="cy(zoomPriceMin)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
<line :x1="cx(zoomVolMin)" :y1="cy(localP0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
<!-- p0 label -->
<text :x="cx(zoomVolMin) + 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)"
/>
<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>
</div>
<!-- ═══════════════════════════════════════════════════════
3. SECTION AUTHENTIFICATION
═══════════════════════════════════════════════════════ -->
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin-bottom: 0.75rem;">Authentification</h3>
<div v-if="isCitizenAuth" class="alert alert-success">
Vous etes authentifie. Vous pouvez ajuster la courbe et sauvegarder votre vote.
</div>
<div v-else class="auth-options">
<!-- Code foyer -->
<div class="auth-option">
<h4>Code foyer</h4>
<p style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">
Entrez le code a 8 caracteres recu par courrier.
</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>
<!-- 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>
<!-- Duniter -->
<div class="auth-option" style="opacity: 0.5;">
<h4>Duniter</h4>
<p style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">
Authentification par identite numerique Duniter.
</p>
<button class="btn btn-secondary" disabled>Bientot disponible</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
GRAPH 2: Static baseline — Modele lineaire actuel
(= 1er graph de eau.py — CurrentModel)
4. SECTION VOTE
═══════════════════════════════════════════════════════ -->
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin-bottom: 0.5rem;">Votre vote</h3>
<p style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 1rem;">
Ajustez la courbe ci-dessus, puis sauvegardez. C'est votre vote. Modifiable a tout moment. La derniere position compte.
</p>
<button
class="btn btn-primary btn-lg"
@click="submitVote"
:disabled="!isCitizenAuth || submitting || isVoteClosed"
>
<span v-if="submitting" class="spinner" style="width: 1rem; height: 1rem;"></span>
Sauvegarder mon vote
</button>
<p v-if="!isCitizenAuth" style="font-size: 0.8rem; color: var(--color-text-muted); margin-top: 0.5rem;">
Authentifiez-vous pour voter.
</p>
<p v-else-if="isVoteClosed" style="font-size: 0.8rem; color: #dc2626; margin-top: 0.5rem;">
Le vote est clos.
</p>
<div v-if="voteSuccess" class="alert alert-success" style="margin-top: 0.75rem;">
Vote enregistre !
</div>
</div>
<!--
5. COMPTE A REBOURS
-->
<div v-if="commune.vote_deadline" class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin-bottom: 0.5rem;">Echeance du vote</h3>
<div v-if="isVoteClosed" style="font-size: 1.1rem; color: #dc2626; font-weight: 600;">
Le vote est clos.
</div>
<div v-else class="countdown">
<span class="countdown-unit"><strong>{{ countdown.days }}</strong>j</span>
<span class="countdown-unit"><strong>{{ countdown.hours }}</strong>h</span>
<span class="countdown-unit"><strong>{{ countdown.minutes }}</strong>m</span>
<span class="countdown-unit"><strong>{{ countdown.seconds }}</strong>s</span>
</div>
</div>
<!--
6. PARAMETRES COURBE + TABLE IMPACTS (2 colonnes)
-->
<div class="params-impacts-layout" style="margin-bottom: 1.5rem;">
<div class="card">
<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>
<div class="card">
<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>
</div>
<!--
7. TARIFICATION ACTUELLE (baseline lineaire)
-->
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin-bottom: 0.5rem;">Tarification actuelle (modele lineaire)</h3>
@@ -244,7 +291,6 @@
<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)"
@@ -257,11 +303,8 @@
<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>
@@ -276,7 +319,6 @@
<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)"
@@ -289,11 +331,8 @@
<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"
@@ -306,8 +345,10 @@
</div>
</div>
<!-- Tariff params info -->
<div class="card">
<!--
8. INFORMATIONS TARIFAIRES
-->
<div class="card" style="margin-bottom: 1.5rem;">
<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>
@@ -317,6 +358,17 @@
<div><strong>{{ params.vmax }} m3</strong><br/><span class="info-label">Volume max</span></div>
</div>
</div>
<!--
9. CONTENU CMS deplace en fin de page
-->
<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>
</template>
</div>
@@ -367,6 +419,39 @@ const isCitizenAuth = computed(() => authStore.isCitizen && authStore.communeSlu
const submitting = ref(false)
const voteSuccess = ref(false)
// ── Countdown ──
const countdown = reactive({ days: 0, hours: 0, minutes: 0, seconds: 0 })
const isVoteClosed = ref(false)
let countdownInterval: ReturnType<typeof setInterval> | null = null
function updateCountdown() {
if (!commune.value?.vote_deadline) return
const now = Date.now()
const deadline = new Date(commune.value.vote_deadline).getTime()
const diff = deadline - now
if (diff <= 0) {
isVoteClosed.value = true
countdown.days = 0
countdown.hours = 0
countdown.minutes = 0
countdown.seconds = 0
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
return
}
isVoteClosed.value = false
countdown.days = Math.floor(diff / 86400000)
countdown.hours = Math.floor((diff % 86400000) / 3600000)
countdown.minutes = Math.floor((diff % 3600000) / 60000)
countdown.seconds = Math.floor((diff % 60000) / 1000)
}
onUnmounted(() => {
if (countdownInterval) clearInterval(countdownInterval)
})
// ── Chart 1: Interactive Bezier with zoom ──
const W = 620
const H = 380
@@ -534,8 +619,6 @@ function handleDrag(pt: { x: number; y: number }) {
break
}
}
// p0 stays fixed during drag — curve updates reactively via bp → cp → SVG paths
// Server will compute authoritative p0 + impacts on mouseUp
}
let serverTimeout: ReturnType<typeof setTimeout> | null = null
@@ -691,10 +774,19 @@ onMounted(async () => {
for (let i = 0; i < (stats.pro_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
households.value = hh
// Load all vote curves for outlier overlay (public endpoint needed)
// Load all vote curves for outlier overlay
try {
outlierVotes.value = await api.get<any[]>(`/communes/${slug}/votes/current/overlay`)
} catch { /* endpoint may not exist yet */ }
// Default zoom to "Population" preset
zoomPreset('tier1')
// Start countdown if deadline set
if (c.vote_deadline) {
updateCountdown()
countdownInterval = setInterval(updateCountdown, 1000)
}
} catch (e: any) {
loadError.value = e.message
} finally {
@@ -746,24 +838,52 @@ function renderMarkdown(md: string): string {
.toggle-label input { cursor: pointer; }
.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);
.chart-container svg path {
stroke-linecap: round;
stroke-linejoin: round;
transition: stroke-width 0.2s ease;
}
.chart-container svg circle {
transition: r 0.2s ease, fill 0.2s ease;
}
.chart-container svg circle:hover {
r: 10;
fill: #1d4ed8;
}
/* Auth options: 2 columns */
.auth-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 700px) {
.auth-options { grid-template-columns: 1fr; }
}
.auth-option {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem;
background: var(--color-bg);
}
/* Params + impacts: 2 columns */
.params-impacts-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 700px) {
.params-impacts-layout { grid-template-columns: 1fr; }
}
.param-grid {
@@ -814,6 +934,24 @@ function renderMarkdown(md: string): string {
font-size: 0.85rem;
}
/* Vote button */
.btn-lg {
padding: 0.75rem 2rem;
font-size: 1.1rem;
font-weight: 600;
}
/* Countdown */
.countdown {
display: flex;
gap: 0.75rem;
font-size: 1.5rem;
}
.countdown-unit strong {
font-size: 2rem;
font-weight: 700;
}
.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; }