diff --git a/backend/alembic/versions/0d7cc7e3efb9_add_vote_deadline_to_communes.py b/backend/alembic/versions/0d7cc7e3efb9_add_vote_deadline_to_communes.py
new file mode 100644
index 0000000..efb4816
--- /dev/null
+++ b/backend/alembic/versions/0d7cc7e3efb9_add_vote_deadline_to_communes.py
@@ -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 ###
diff --git a/backend/app/models/models.py b/backend/app/models/models.py
index b9b9438..a209f52 100644
--- a/backend/app/models/models.py
+++ b/backend/app/models/models.py
@@ -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")
diff --git a/backend/app/routers/communes.py b/backend/app/routers/communes.py
index a4f3d53..302e117 100644
--- a/backend/app/routers/communes.py
+++ b/backend/app/routers/communes.py
@@ -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)
diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py
index cce8571..c6a3509 100644
--- a/backend/app/schemas/schemas.py
+++ b/backend/app/schemas/schemas.py
@@ -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}
diff --git a/frontend/app/components/charts/VoteOverlayChart.vue b/frontend/app/components/charts/VoteOverlayChart.vue
index 592c8dc..77cff19 100644
--- a/frontend/app/components/charts/VoteOverlayChart.vue
+++ b/frontend/app/components/charts/VoteOverlayChart.vue
@@ -19,14 +19,14 @@
-
-
+
+
-
-
+
+
@@ -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;
}
diff --git a/frontend/app/pages/admin/communes/[slug]/index.vue b/frontend/app/pages/admin/communes/[slug]/index.vue
index 1df709e..aaeb1b8 100644
--- a/frontend/app/pages/admin/communes/[slug]/index.vue
+++ b/frontend/app/pages/admin/communes/[slug]/index.vue
@@ -29,6 +29,29 @@
+
+
+
Parametres du vote
+
+
+
+
+
+
+
Enregistre !
+
+
+
Statistiques foyers
@@ -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
(`/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
}
diff --git a/frontend/app/pages/commune/[slug]/index.vue b/frontend/app/pages/commune/[slug]/index.vue
index 258dba4..aae4e5b 100644
--- a/frontend/app/pages/commune/[slug]/index.vue
+++ b/frontend/app/pages/commune/[slug]/index.vue
@@ -1,5 +1,6 @@
+
-
-
@@ -45,8 +39,8 @@
-
-
+
+
@@ -55,183 +49,236 @@
-
-
-
-
+
+
+
+
Authentification
+
+
+ Vous etes authentifie. Vous pouvez ajuster la courbe et sauvegarder votre vote.
+
+
+
+
+
+
Code foyer
+
+ Entrez le code a 8 caracteres recu par courrier.
+
+
{{ authError }}
+
-
-
-
-
Parametres de la courbe
-
-
- vinf
- {{ bp.vinf.toFixed(0) }} m3
-
-
- a
- {{ bp.a.toFixed(3) }}
-
-
- b
- {{ bp.b.toFixed(3) }}
-
-
- c
- {{ bp.c.toFixed(3) }}
-
-
- d
- {{ bp.d.toFixed(3) }}
-
-
- e
- {{ bp.e.toFixed(3) }}
-
-
- p0
- {{ localP0.toFixed(2) }} EUR/m3
-
-
-
-
-
-
-
Impact par volume
-
-
- | Vol. | Ancien | Nouveau RP | Nouveau RS |
-
-
-
- | {{ imp.volume }} m3 |
- {{ imp.old_price.toFixed(0) }} EUR |
-
- {{ imp.new_price_rp.toFixed(0) }} EUR
- |
-
- {{ imp.new_price_rs.toFixed(0) }} EUR
- |
-
-
-
-
-
-
-
-
-
- Pour soumettre votre vote, entrez votre code foyer :
-
-
{{ authError }}
-
-
-
-
-
- Vote enregistre !
-
-
-
+
+
+
Duniter
+
+ Authentification par identite numerique Duniter.
+
+
+
+
Votre vote
+
+ Ajustez la courbe ci-dessus, puis sauvegardez. C'est votre vote. Modifiable a tout moment. La derniere position compte.
+
+
+
+ Authentifiez-vous pour voter.
+
+
+ Le vote est clos.
+
+
+ Vote enregistre !
+
+
+
+
+
+
Echeance du vote
+
+ Le vote est clos.
+
+
+ {{ countdown.days }}j
+ {{ countdown.hours }}h
+ {{ countdown.minutes }}m
+ {{ countdown.seconds }}s
+
+
+
+
+
+
+
Parametres de la courbe
+
+
+ vinf
+ {{ bp.vinf.toFixed(0) }} m3
+
+
+ a
+ {{ bp.a.toFixed(3) }}
+
+
+ b
+ {{ bp.b.toFixed(3) }}
+
+
+ c
+ {{ bp.c.toFixed(3) }}
+
+
+ d
+ {{ bp.d.toFixed(3) }}
+
+
+ e
+ {{ bp.e.toFixed(3) }}
+
+
+ p0
+ {{ localP0.toFixed(2) }} EUR/m3
+
+
+
+
+
+
Impact par volume
+
+
+ | Vol. | Ancien | Nouveau RP | Nouveau RS |
+
+
+
+ | {{ imp.volume }} m3 |
+ {{ imp.old_price.toFixed(0) }} EUR |
+
+ {{ imp.new_price_rp.toFixed(0) }} EUR
+ |
+
+ {{ imp.new_price_rs.toFixed(0) }} EUR
+ |
+
+
+
+
+
+
+
Tarification actuelle (modele lineaire)
@@ -244,7 +291,6 @@
Facture totale (EUR)
-
{{ b }}
-
-
-
RP/PRO
@@ -276,7 +319,6 @@
Prix au m3 (EUR)
-
{{ p }}
-
-
-
-
-
+
+
Informations tarifaires
{{ params.recettes.toLocaleString() }} EUR
Recettes cibles
@@ -317,6 +358,17 @@
{{ params.vmax }} m3
Volume max
+
+
+
+
@@ -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 | 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 | 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(`/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; }