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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;">← 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; }
|
||||
|
||||
Reference in New Issue
Block a user