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:
@@ -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 ###
|
||||||
@@ -29,6 +29,7 @@ class Commune(Base):
|
|||||||
description = Column(Text, default="")
|
description = Column(Text, default="")
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
vote_deadline = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
tariff_params = relationship("TariffParams", back_populates="commune", uselist=False)
|
tariff_params = relationship("TariffParams", back_populates="commune", uselist=False)
|
||||||
households = relationship("Household", back_populates="commune")
|
households = relationship("Household", back_populates="commune")
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ async def update_commune(
|
|||||||
commune.description = data.description
|
commune.description = data.description
|
||||||
if data.is_active is not None:
|
if data.is_active is not None:
|
||||||
commune.is_active = data.is_active
|
commune.is_active = data.is_active
|
||||||
|
if data.vote_deadline is not None:
|
||||||
|
commune.vote_deadline = data.vote_deadline
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(commune)
|
await db.refresh(commune)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class CommuneUpdate(BaseModel):
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
vote_deadline: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class CommuneOut(BaseModel):
|
class CommuneOut(BaseModel):
|
||||||
@@ -44,6 +45,7 @@ class CommuneOut(BaseModel):
|
|||||||
description: str
|
description: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
vote_deadline: datetime | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,14 @@
|
|||||||
|
|
||||||
<!-- Vote curves (semi-transparent) -->
|
<!-- Vote curves (semi-transparent) -->
|
||||||
<g v-for="(vote, i) in votes" :key="i">
|
<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, 1)" fill="none" stroke="#3b82f6" stroke-width="1.5" opacity="0.4" />
|
||||||
<path :d="getVotePath(vote, 2)" fill="none" stroke="#ef4444" stroke-width="1" opacity="0.3" />
|
<path :d="getVotePath(vote, 2)" fill="none" stroke="#ef4444" stroke-width="1.5" opacity="0.4" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Median curve (if available) -->
|
<!-- Median curve (if available) -->
|
||||||
<g v-if="medianVote">
|
<g v-if="medianVote">
|
||||||
<path :d="getVotePath(medianVote, 1)" fill="none" stroke="#1e40af" 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="3" />
|
<path :d="getVotePath(medianVote, 2)" fill="none" stroke="#991b1b" stroke-width="4" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Axis labels -->
|
<!-- Axis labels -->
|
||||||
@@ -108,5 +108,13 @@ onMounted(async () => {
|
|||||||
.overlay-chart svg {
|
.overlay-chart svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -29,6 +29,29 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</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 -->
|
<!-- Stats -->
|
||||||
<div v-if="stats" class="card" style="margin-bottom: 2rem;">
|
<div v-if="stats" class="card" style="margin-bottom: 2rem;">
|
||||||
<h3 style="margin-bottom: 1rem;">Statistiques foyers</h3>
|
<h3 style="margin-bottom: 1rem;">Statistiques foyers</h3>
|
||||||
@@ -145,6 +168,26 @@ const search = ref('')
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const perPage = 20
|
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(() => {
|
const filteredHouseholds = computed(() => {
|
||||||
if (!search.value) return households.value
|
if (!search.value) return households.value
|
||||||
const q = search.value.toLowerCase()
|
const q = search.value.toLowerCase()
|
||||||
@@ -173,6 +216,10 @@ function statusBadge(status: string) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
commune.value = await api.get<any>(`/communes/${slug}`)
|
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) {
|
} catch (e: any) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="commune">
|
<div v-if="commune">
|
||||||
|
<!-- ── 1. Header ── -->
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<NuxtLink to="/" style="color: var(--color-text-muted); font-size: 0.875rem;">← Toutes les communes</NuxtLink>
|
<NuxtLink to="/" style="color: var(--color-text-muted); font-size: 0.875rem;">← Toutes les communes</NuxtLink>
|
||||||
<h1>{{ commune.name }}</h1>
|
<h1>{{ commune.name }}</h1>
|
||||||
@@ -13,16 +14,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="curveData">
|
<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 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;">
|
<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 -->
|
<!-- Zoom controls -->
|
||||||
<div class="zoom-bar">
|
<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('tier1')" title="Zoom population">Population</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('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="zoomIn">+</button>
|
||||||
<button class="btn btn-secondary btn-xs" @click="zoomOut">-</button>
|
<button class="btn btn-secondary btn-xs" @click="zoomOut">-</button>
|
||||||
<button class="btn btn-secondary btn-xs" @click="zoomReset">Reset</button>
|
<button class="btn btn-secondary btn-xs" @click="zoomReset">Reset</button>
|
||||||
@@ -55,8 +49,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-layout">
|
<!-- SVG: full-width, no side panel -->
|
||||||
<!-- SVG: Prix au m3 vs Volume (the Bezier curve) -->
|
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<svg
|
<svg
|
||||||
ref="svgRef"
|
ref="svgRef"
|
||||||
@@ -78,17 +71,17 @@
|
|||||||
:x1="cx(zoomVolMin)" :y1="cy(p)" :x2="cx(zoomVolMax)" :y2="cy(p)"
|
:x1="cx(zoomVolMin)" :y1="cy(p)" :x2="cx(zoomVolMax)" :y2="cy(p)"
|
||||||
stroke="#e2e8f0" stroke-width="0.5" />
|
stroke="#e2e8f0" stroke-width="0.5" />
|
||||||
<text v-for="v in gridVols" :key="'lv'+v"
|
<text v-for="v in gridVols" :key="'lv'+v"
|
||||||
:x="cx(v)" :y="H - 2" text-anchor="middle" font-size="10" fill="#94a3b8">
|
:x="cx(v)" :y="H - 2" text-anchor="middle" font-size="12" fill="#64748b">
|
||||||
{{ v }}
|
{{ v }}
|
||||||
</text>
|
</text>
|
||||||
<text v-for="p in gridPrices" :key="'lp'+p"
|
<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">
|
:x="margin.left - 4" :y="cy(p) + 3" text-anchor="end" font-size="12" fill="#64748b">
|
||||||
{{ p.toFixed(p < 1 ? 1 : 0) }}
|
{{ p.toFixed(p < 1 ? 1 : 0) }}
|
||||||
</text>
|
</text>
|
||||||
<text :x="W / 2" :y="H - 0" text-anchor="middle" font-size="10" fill="#64748b">
|
<text :x="W / 2" :y="H - 0" text-anchor="middle" font-size="12" fill="#475569">
|
||||||
volume (m3)
|
volume (m3)
|
||||||
</text>
|
</text>
|
||||||
<text :x="12" :y="margin.top - 4" font-size="10" fill="#64748b">
|
<text :x="12" :y="margin.top - 4" font-size="12" fill="#475569">
|
||||||
EUR/m3
|
EUR/m3
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -111,9 +104,9 @@
|
|||||||
<line :x1="cx(cp.p6.x)" :y1="cy(cp.p6.y)" :x2="cx(cp.p7.x)" :y2="cy(cp.p7.y)"
|
<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" />
|
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
|
||||||
|
|
||||||
<!-- Bezier curve: tier 1 (blue, thicker = focus) -->
|
<!-- Bezier curve: population (blue, thicker = focus) -->
|
||||||
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="3" />
|
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="3" />
|
||||||
<!-- Bezier curve: tier 2 (red) -->
|
<!-- Bezier curve: cas exceptionnels (red) -->
|
||||||
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2" />
|
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2" />
|
||||||
|
|
||||||
<!-- Inflection reference lines -->
|
<!-- Inflection reference lines -->
|
||||||
@@ -143,10 +136,92 @@
|
|||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Right panel: parameters + impacts -->
|
<!-- ═══════════════════════════════════════════════════════
|
||||||
<div class="side-panel">
|
3. SECTION AUTHENTIFICATION
|
||||||
<div class="card" style="margin-bottom: 1rem;">
|
═══════════════════════════════════════════════════════ -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════
|
||||||
|
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>
|
<h4 style="margin-bottom: 0.5rem;">Parametres de la courbe</h4>
|
||||||
<div class="param-grid">
|
<div class="param-grid">
|
||||||
<div class="param-row">
|
<div class="param-row">
|
||||||
@@ -180,8 +255,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Impact table -->
|
<div class="card">
|
||||||
<div class="card" style="margin-bottom: 1rem;">
|
|
||||||
<h4 style="margin-bottom: 0.5rem;">Impact par volume</h4>
|
<h4 style="margin-bottom: 0.5rem;">Impact par volume</h4>
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -201,37 +275,10 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════
|
||||||
GRAPH 2: Static baseline — Modele lineaire actuel
|
7. TARIFICATION ACTUELLE (baseline lineaire)
|
||||||
(= 1er graph de eau.py — CurrentModel)
|
|
||||||
═══════════════════════════════════════════════════════ -->
|
═══════════════════════════════════════════════════════ -->
|
||||||
<div class="card" style="margin-bottom: 1.5rem;">
|
<div class="card" style="margin-bottom: 1.5rem;">
|
||||||
<h3 style="margin-bottom: 0.5rem;">Tarification actuelle (modele lineaire)</h3>
|
<h3 style="margin-bottom: 0.5rem;">Tarification actuelle (modele lineaire)</h3>
|
||||||
@@ -244,7 +291,6 @@
|
|||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<h4 style="text-align: center; font-size: 0.85rem; margin-bottom: 0.25rem;">Facture totale (EUR)</h4>
|
<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">
|
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
|
||||||
<!-- Grid -->
|
|
||||||
<g>
|
<g>
|
||||||
<line v-for="v in gridVols2" :key="'bg1v'+v"
|
<line v-for="v in gridVols2" :key="'bg1v'+v"
|
||||||
:x1="cx2(v)" :y1="cy2bill(0)" :x2="cx2(v)" :y2="cy2bill(maxBill)"
|
: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"
|
<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>
|
:x="margin2.left - 4" :y="cy2bill(b) + 3" text-anchor="end" font-size="9" fill="#94a3b8">{{ b }}</text>
|
||||||
</g>
|
</g>
|
||||||
<!-- RP curve -->
|
|
||||||
<polyline :points="baselineBillRP" fill="none" stroke="#2563eb" stroke-width="1.5" />
|
<polyline :points="baselineBillRP" fill="none" stroke="#2563eb" stroke-width="1.5" />
|
||||||
<!-- RS curve -->
|
|
||||||
<polyline :points="baselineBillRS" fill="none" stroke="#dc2626" stroke-width="1.5" />
|
<polyline :points="baselineBillRS" fill="none" stroke="#dc2626" stroke-width="1.5" />
|
||||||
<!-- Legend -->
|
|
||||||
<g :transform="`translate(${W2 - 100}, 15)`">
|
<g :transform="`translate(${W2 - 100}, 15)`">
|
||||||
<line x1="0" y1="0" x2="15" y2="0" stroke="#2563eb" stroke-width="1.5" />
|
<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>
|
<text x="18" y="3" font-size="9" fill="#1e293b">RP/PRO</text>
|
||||||
@@ -276,7 +319,6 @@
|
|||||||
<div class="chart-container">
|
<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>
|
<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">
|
<svg :viewBox="`0 0 ${W2} ${H2}`" preserveAspectRatio="xMidYMid meet">
|
||||||
<!-- Grid -->
|
|
||||||
<g>
|
<g>
|
||||||
<line v-for="v in gridVols2" :key="'bg2v'+v"
|
<line v-for="v in gridVols2" :key="'bg2v'+v"
|
||||||
:x1="cx2(v)" :y1="cy2price(0)" :x2="cx2(v)" :y2="cy2price(pmax)"
|
: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"
|
<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>
|
:x="margin2.left - 4" :y="cy2price(p) + 3" text-anchor="end" font-size="9" fill="#94a3b8">{{ p }}</text>
|
||||||
</g>
|
</g>
|
||||||
<!-- RP price/m3 curve (hyperbolic) -->
|
|
||||||
<polyline :points="baselinePriceRP" fill="none" stroke="#2563eb" stroke-width="1.5" />
|
<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" />
|
<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)"
|
<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" />
|
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
||||||
<text :x="cx2(vmax) - 5" :y="cy2price(curveData.p0_linear) - 5" text-anchor="end"
|
<text :x="cx2(vmax) - 5" :y="cy2price(curveData.p0_linear) - 5" text-anchor="end"
|
||||||
@@ -306,8 +345,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<h3 style="margin-bottom: 0.75rem;">Informations tarifaires</h3>
|
||||||
<div v-if="params" class="grid grid-5-info">
|
<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.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><strong>{{ params.vmax }} m3</strong><br/><span class="info-label">Volume max</span></div>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -367,6 +419,39 @@ const isCitizenAuth = computed(() => authStore.isCitizen && authStore.communeSlu
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const voteSuccess = 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 ──
|
// ── Chart 1: Interactive Bezier with zoom ──
|
||||||
const W = 620
|
const W = 620
|
||||||
const H = 380
|
const H = 380
|
||||||
@@ -534,8 +619,6 @@ function handleDrag(pt: { x: number; y: number }) {
|
|||||||
break
|
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
|
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' })
|
for (let i = 0; i < (stats.pro_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
|
||||||
households.value = hh
|
households.value = hh
|
||||||
|
|
||||||
// Load all vote curves for outlier overlay (public endpoint needed)
|
// Load all vote curves for outlier overlay
|
||||||
try {
|
try {
|
||||||
outlierVotes.value = await api.get<any[]>(`/communes/${slug}/votes/current/overlay`)
|
outlierVotes.value = await api.get<any[]>(`/communes/${slug}/votes/current/overlay`)
|
||||||
} catch { /* endpoint may not exist yet */ }
|
} 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) {
|
} catch (e: any) {
|
||||||
loadError.value = e.message
|
loadError.value = e.message
|
||||||
} finally {
|
} finally {
|
||||||
@@ -746,24 +838,52 @@ function renderMarkdown(md: string): string {
|
|||||||
|
|
||||||
.toggle-label input { cursor: pointer; }
|
.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 {
|
.chart-container svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-panel .card {
|
.chart-container svg path {
|
||||||
background: var(--color-bg);
|
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: 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 {
|
.param-grid {
|
||||||
@@ -814,6 +934,24 @@ function renderMarkdown(md: string): string {
|
|||||||
font-size: 0.85rem;
|
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 { 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(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(h3) { font-size: 1.05rem; margin: 0.5rem 0 0.25rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user