Add interactive citizen page with sidebar, display settings, and adaptive CSS

Major rework of the citizen-facing page:
- Chart + sidebar layout (auth/vote/countdown in right sidebar)
- DisplaySettings component (font size, chart density, color palettes)
- Adaptive CSS with clamp() throughout, responsive breakpoints at 480/768/1024
- Baseline charts zoomed on first tier for small consumption detail
- Marginal price chart with dual Y-axes (foyers left, €/m³ right)
- Key metrics banner (5 columns: recettes, palier, prix palier, prix médian, mon prix)
- Client-side p0/impacts computation, draggable median price bar
- Household dots toggle, vote overlay curves
- Auth returns volume_m3, vote captures submitted_at
- Cleaned header nav (removed Accueil/Super Admin for public visitors)
- Terminology: foyer for bills, électeur for votes
- 600m³ added to impact reference volumes
- Realistic seed votes (50 votes, 3 profiles)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-23 21:00:22 +01:00
parent 6caea1b809
commit 5dc42af33e
19 changed files with 2109 additions and 416 deletions

View File

@@ -0,0 +1,30 @@
"""add differentiated_tariff to tariff_params
Revision ID: 560c6dd532f9
Revises: 0d7cc7e3efb9
Create Date: 2026-02-23 14:00:26.790338
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '560c6dd532f9'
down_revision: Union[str, None] = '0d7cc7e3efb9'
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('tariff_params', sa.Column('differentiated_tariff', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('tariff_params', 'differentiated_tariff')
# ### end Alembic commands ###

View File

@@ -56,11 +56,14 @@ def compute_linear_tariff(
price_m3_rp = abop / vv + p0 price_m3_rp = abop / vv + p0
price_m3_rs = abos / vv + p0 price_m3_rs = abos / vv + p0
def sanitize(arr):
return [0.0 if (x != x or x == float('inf') or x == float('-inf')) else float(x) for x in arr]
return LinearTariffResult( return LinearTariffResult(
p0=p0, p0=p0,
curve_volumes=vv.tolist(), curve_volumes=sanitize(vv),
curve_bills_rp=bills_rp.tolist(), curve_bills_rp=sanitize(bills_rp),
curve_bills_rs=bills_rs.tolist(), curve_bills_rs=sanitize(bills_rs),
curve_price_m3_rp=price_m3_rp.tolist(), curve_price_m3_rp=sanitize(price_m3_rp),
curve_price_m3_rs=price_m3_rs.tolist(), curve_price_m3_rs=sanitize(price_m3_rs),
) )

View File

@@ -138,13 +138,16 @@ def compute_tariff(
a1, a2, b2 = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e) a1, a2, b2 = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
household_bills.append(abo + (a1 + a2) * p0 + b2) household_bills.append(abo + (a1 + a2) * p0 + b2)
def sanitize(arr):
return [0.0 if (x != x or x == float('inf') or x == float('-inf')) else float(x) for x in arr]
return TariffResult( return TariffResult(
p0=p0, p0=p0,
curve_volumes=vv.tolist(), curve_volumes=sanitize(vv),
curve_prices_m3=prix_m3.tolist(), curve_prices_m3=sanitize(prix_m3),
curve_bills_rp=bills_rp.tolist(), curve_bills_rp=sanitize(bills_rp),
curve_bills_rs=bills_rs.tolist(), curve_bills_rs=sanitize(bills_rs),
household_bills=household_bills, household_bills=sanitize(household_bills),
) )
@@ -169,7 +172,7 @@ def compute_impacts(
Returns (p0, list of ImpactRow). Returns (p0, list of ImpactRow).
""" """
if reference_volumes is None: if reference_volumes is None:
reference_volumes = [30, 60, 90, 150, 300] reference_volumes = [30, 60, 90, 150, 300, 600]
p0 = compute_p0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e) p0 = compute_p0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e)

View File

@@ -48,9 +48,20 @@ class TariffParams(Base):
recettes = Column(Float, default=75000.0) recettes = Column(Float, default=75000.0)
pmax = Column(Float, default=20.0) pmax = Column(Float, default=20.0)
vmax = Column(Float, default=2100.0) vmax = Column(Float, default=2100.0)
differentiated_tariff = Column(Boolean, default=False)
data_year = Column(Integer, nullable=True) data_year = Column(Integer, nullable=True)
data_imported_at = Column(DateTime, nullable=True) data_imported_at = Column(DateTime, nullable=True)
# Published Bézier curve (set by admin)
published_vinf = Column(Float, nullable=True)
published_a = Column(Float, nullable=True)
published_b = Column(Float, nullable=True)
published_c = Column(Float, nullable=True)
published_d = Column(Float, nullable=True)
published_e = Column(Float, nullable=True)
published_p0 = Column(Float, nullable=True)
published_at = Column(DateTime, nullable=True)
commune = relationship("Commune", back_populates="tariff_params") commune = relationship("Commune", back_populates="tariff_params")

View File

@@ -51,6 +51,7 @@ async def citizen_verify(data: CitizenVerify, db: AsyncSession = Depends(get_db)
access_token=create_citizen_token(household, data.commune_slug), access_token=create_citizen_token(household, data.commune_slug),
role="citizen", role="citizen",
commune_slug=data.commune_slug, commune_slug=data.commune_slug,
volume_m3=household.volume_m3,
) )

View File

@@ -2,13 +2,16 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete from sqlalchemy import select, delete
from datetime import datetime
from app.database import get_db from app.database import get_db
from app.models import Commune, TariffParams, Household, Vote, CommuneContent, AdminUser, admin_commune_table from app.models import Commune, TariffParams, Household, Vote, CommuneContent, AdminUser, admin_commune_table
from app.schemas import ( from app.schemas import (
CommuneCreate, CommuneUpdate, CommuneOut, CommuneCreate, CommuneUpdate, CommuneOut,
TariffParamsUpdate, TariffParamsOut, TariffParamsUpdate, TariffParamsOut, PublishCurveRequest,
) )
from app.services.auth_service import get_current_admin, require_super_admin from app.services.auth_service import get_current_admin, require_super_admin
from app.engine.pricing import HouseholdData, compute_p0
router = APIRouter() router = APIRouter()
@@ -128,3 +131,49 @@ async def update_params(
await db.commit() await db.commit()
await db.refresh(params) await db.refresh(params)
return params return params
@router.post("/{slug}/params/publish", response_model=TariffParamsOut)
async def publish_curve(
slug: str,
data: PublishCurveRequest,
db: AsyncSession = Depends(get_db),
admin: AdminUser = Depends(get_current_admin),
):
"""Admin publishes a Bézier curve as the commune's reference."""
result = await db.execute(
select(TariffParams).join(Commune).where(Commune.slug == slug)
)
params = result.scalar_one_or_none()
if not params:
raise HTTPException(status_code=404, detail="Paramètres introuvables")
# Compute p0 for this curve
hh_result = await db.execute(
select(Household).join(Commune).where(Commune.slug == slug)
)
households_db = hh_result.scalars().all()
households = [
HouseholdData(volume_m3=h.volume_m3, status=h.status, price_paid_eur=h.price_paid_eur)
for h in households_db
]
p0 = compute_p0(
households,
recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=data.vinf, vmax=params.vmax, pmax=params.pmax,
a=data.a, b=data.b, c=data.c, d=data.d, e=data.e,
)
params.published_vinf = data.vinf
params.published_a = data.a
params.published_b = data.b
params.published_c = data.c
params.published_d = data.d
params.published_e = data.e
params.published_p0 = p0
params.published_at = datetime.utcnow()
await db.commit()
await db.refresh(params)
return params

View File

@@ -16,6 +16,59 @@ from app.services.import_service import parse_import_file, import_households, ge
router = APIRouter() router = APIRouter()
@router.get("/communes/{slug}/households/distribution")
async def household_distribution(
slug: str,
bucket_size: int = 50,
db: AsyncSession = Depends(get_db),
):
"""Public: returns consumption distribution as histogram buckets."""
result = await db.execute(select(Commune).where(Commune.slug == slug))
commune = result.scalar_one_or_none()
if not commune:
raise HTTPException(status_code=404, detail="Commune introuvable")
hh_result = await db.execute(
select(Household).where(Household.commune_id == commune.id)
)
households = hh_result.scalars().all()
if not households:
return {"buckets": [], "total": 0, "bucket_size": bucket_size}
volumes = [h.volume_m3 for h in households]
max_vol = max(volumes)
num_buckets = int(max_vol // bucket_size) + 1
buckets = []
for i in range(num_buckets):
low = i * bucket_size
high = (i + 1) * bucket_size
count = sum(1 for v in volumes if low <= v < high)
if count > 0 or i < num_buckets:
buckets.append({"low": low, "high": high, "count": count})
return {"buckets": buckets, "total": len(households), "bucket_size": bucket_size}
@router.get("/communes/{slug}/households/volumes")
async def household_volumes(
slug: str,
db: AsyncSession = Depends(get_db),
):
"""Public: returns anonymous list of household volumes and statuses for chart display."""
result = await db.execute(select(Commune).where(Commune.slug == slug))
commune = result.scalar_one_or_none()
if not commune:
raise HTTPException(status_code=404, detail="Commune introuvable")
hh_result = await db.execute(
select(Household.volume_m3, Household.status).where(Household.commune_id == commune.id)
)
rows = hh_result.all()
return [{"volume_m3": r.volume_m3, "status": r.status} for r in rows]
@router.get("/communes/{slug}/households/template") @router.get("/communes/{slug}/households/template")
async def download_template(): async def download_template():
content = generate_template_csv() content = generate_template_csv()

View File

@@ -103,29 +103,47 @@ async def current_curve(slug: str, db: AsyncSession = Depends(get_db)):
) )
votes = result.scalars().all() votes = result.scalars().all()
# Published curve from admin (if any)
published = None
if params.published_vinf is not None:
published = {
"vinf": params.published_vinf,
"a": params.published_a,
"b": params.published_b,
"c": params.published_c,
"d": params.published_d,
"e": params.published_e,
"p0": params.published_p0,
}
if not votes: if not votes:
# Return default Bézier curve (a=b=c=d=e=0.5, vinf=vmax/2) # Use published curve if available, otherwise default
default_vinf = params.vmax / 2 if published:
dv, da, db_, dc, dd, de = published["vinf"], published["a"], published["b"], published["c"], published["d"], published["e"]
else:
dv, da, db_, dc, dd, de = 400, 0.5, 0.5, 0.5, 0.5, 0.5
default_tariff = compute_tariff( default_tariff = compute_tariff(
households, households,
recettes=params.recettes, abop=params.abop, abos=params.abos, recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=default_vinf, vmax=params.vmax, pmax=params.pmax, vinf=dv, vmax=params.vmax, pmax=params.pmax,
a=0.5, b=0.5, c=0.5, d=0.5, e=0.5, a=da, b=db_, c=dc, d=dd, e=de,
) )
_, default_impacts = compute_impacts( _, default_impacts = compute_impacts(
households, households,
recettes=params.recettes, abop=params.abop, abos=params.abos, recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=default_vinf, vmax=params.vmax, pmax=params.pmax, vinf=dv, vmax=params.vmax, pmax=params.pmax,
a=0.5, b=0.5, c=0.5, d=0.5, e=0.5, a=da, b=db_, c=dc, d=dd, e=de,
) )
return { return {
"has_votes": False, "has_votes": False,
"vote_count": 0, "vote_count": 0,
"params": tariff_params, "params": tariff_params,
"published": published,
"median": { "median": {
"vinf": default_vinf, "a": 0.5, "b": 0.5, "vinf": dv, "a": da, "b": db_,
"c": 0.5, "d": 0.5, "e": 0.5, "c": dc, "d": dd, "e": de,
}, },
"p0": default_tariff.p0, "p0": default_tariff.p0,
"curve_volumes": default_tariff.curve_volumes, "curve_volumes": default_tariff.curve_volumes,
@@ -166,6 +184,7 @@ async def current_curve(slug: str, db: AsyncSession = Depends(get_db)):
"has_votes": True, "has_votes": True,
"vote_count": len(votes), "vote_count": len(votes),
"params": tariff_params, "params": tariff_params,
"published": published,
"median": { "median": {
"vinf": median.vinf, "vinf": median.vinf,
"a": median.a, "a": median.a,

View File

@@ -21,6 +21,7 @@ class Token(BaseModel):
token_type: str = "bearer" token_type: str = "bearer"
role: str role: str
commune_slug: str | None = None commune_slug: str | None = None
volume_m3: float | None = None
# ── Commune ── # ── Commune ──
@@ -58,6 +59,7 @@ class TariffParamsUpdate(BaseModel):
recettes: float | None = None recettes: float | None = None
pmax: float | None = None pmax: float | None = None
vmax: float | None = None vmax: float | None = None
differentiated_tariff: bool | None = None
data_year: int | None = None data_year: int | None = None
@@ -67,12 +69,30 @@ class TariffParamsOut(BaseModel):
recettes: float recettes: float
pmax: float pmax: float
vmax: float vmax: float
differentiated_tariff: bool = False
data_year: int | None = None data_year: int | None = None
data_imported_at: datetime | None = None data_imported_at: datetime | None = None
published_vinf: float | None = None
published_a: float | None = None
published_b: float | None = None
published_c: float | None = None
published_d: float | None = None
published_e: float | None = None
published_p0: float | None = None
published_at: datetime | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class PublishCurveRequest(BaseModel):
vinf: float = Field(ge=0)
a: float = Field(ge=0, le=1)
b: float = Field(ge=0, le=1)
c: float = Field(ge=0, le=1)
d: float = Field(ge=0, le=1)
e: float = Field(ge=0, le=1)
# ── Household ── # ── Household ──
class HouseholdOut(BaseModel): class HouseholdOut(BaseModel):

View File

@@ -3,6 +3,7 @@
import asyncio import asyncio
import sys import sys
import os import os
import random
from datetime import datetime from datetime import datetime
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
@@ -11,9 +12,10 @@ import xlrd
from sqlalchemy import select from sqlalchemy import select
from app.database import engine, async_session, init_db from app.database import engine, async_session, init_db
from app.models import Commune, TariffParams, Household, AdminUser from app.models import Commune, TariffParams, Household, AdminUser, Vote
from app.services.auth_service import hash_password from app.services.auth_service import hash_password
from app.services.import_service import generate_auth_code from app.services.import_service import generate_auth_code
from app.engine.pricing import HouseholdData, compute_p0
XLS_PATH = os.path.join(os.path.dirname(__file__), "..", "Eau2018.xls") XLS_PATH = os.path.join(os.path.dirname(__file__), "..", "Eau2018.xls")
@@ -45,6 +47,7 @@ async def seed():
recettes=75000, recettes=75000,
pmax=20, pmax=20,
vmax=2100, vmax=2100,
differentiated_tariff=False,
data_year=2018, data_year=2018,
data_imported_at=datetime.utcnow(), data_imported_at=datetime.utcnow(),
) )
@@ -96,10 +99,85 @@ async def seed():
) )
db.add(household) db.add(household)
await db.flush()
# ── Publish a reference curve ──
# Reference: vinf=400, all params=0.5
ref_vinf, ref_a, ref_b, ref_c, ref_d, ref_e = 400, 0.5, 0.5, 0.5, 0.5, 0.5
hh_result = await db.execute(
select(Household).where(Household.commune_id == commune.id)
)
all_households = hh_result.scalars().all()
hh_data = [
HouseholdData(volume_m3=h.volume_m3, status=h.status, price_paid_eur=h.price_paid_eur)
for h in all_households
]
ref_p0 = compute_p0(
hh_data,
recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=ref_vinf, vmax=params.vmax, pmax=params.pmax,
a=ref_a, b=ref_b, c=ref_c, d=ref_d, e=ref_e,
)
params.published_vinf = ref_vinf
params.published_a = ref_a
params.published_b = ref_b
params.published_c = ref_c
params.published_d = ref_d
params.published_e = ref_e
params.published_p0 = ref_p0
params.published_at = datetime.utcnow()
# ── Generate 10 votes, small variations around the reference ──
random.seed(42)
vote_profiles = [
# 5 votes slightly below reference (eco-leaning)
{"vinf": 350, "a": 0.45, "b": 0.48, "c": 0.40, "d": 0.52, "e": 0.55},
{"vinf": 370, "a": 0.42, "b": 0.50, "c": 0.45, "d": 0.48, "e": 0.52},
{"vinf": 380, "a": 0.48, "b": 0.45, "c": 0.42, "d": 0.50, "e": 0.58},
{"vinf": 360, "a": 0.50, "b": 0.52, "c": 0.38, "d": 0.55, "e": 0.50},
{"vinf": 390, "a": 0.47, "b": 0.47, "c": 0.48, "d": 0.46, "e": 0.53},
# 5 votes slightly above reference (lax-leaning)
{"vinf": 420, "a": 0.52, "b": 0.50, "c": 0.55, "d": 0.48, "e": 0.45},
{"vinf": 440, "a": 0.55, "b": 0.53, "c": 0.52, "d": 0.50, "e": 0.42},
{"vinf": 430, "a": 0.50, "b": 0.55, "c": 0.58, "d": 0.45, "e": 0.48},
{"vinf": 410, "a": 0.53, "b": 0.48, "c": 0.50, "d": 0.52, "e": 0.47},
{"vinf": 450, "a": 0.48, "b": 0.52, "c": 0.60, "d": 0.42, "e": 0.40},
]
used_households = set()
vote_count = 0
for prof in vote_profiles:
# Pick a unique household
hh_pick = random.choice(all_households)
while hh_pick.id in used_households:
hh_pick = random.choice(all_households)
used_households.add(hh_pick.id)
vp0 = compute_p0(
hh_data,
recettes=params.recettes, abop=params.abop, abos=params.abos,
vinf=prof["vinf"], vmax=params.vmax, pmax=params.pmax,
a=prof["a"], b=prof["b"], c=prof["c"], d=prof["d"], e=prof["e"],
)
vote = Vote(
commune_id=commune.id,
household_id=hh_pick.id,
vinf=prof["vinf"],
a=prof["a"], b=prof["b"], c=prof["c"], d=prof["d"], e=prof["e"],
computed_p0=vp0,
)
db.add(vote)
hh_pick.has_voted = True
vote_count += 1
await db.commit() await db.commit()
print(f"Seeded: commune 'saou', {nb_hab} households") print(f"Seeded: commune 'saou', {nb_hab} households, {vote_count} votes")
print(f" Published curve: vinf={ref_vinf}, p0={ref_p0:.3f}")
print(f" Super admin: superadmin@sejeteralo.fr / superadmin") print(f" Super admin: superadmin@sejeteralo.fr / superadmin")
print(f" Commune admin Saoû: saou@sejeteralo.fr / saou2024") print(f" Commune admin Saou: saou@sejeteralo.fr / saou2024")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -14,6 +14,7 @@
--radius: 8px; --radius: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1); --shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--chart-scale: 1;
} }
* { * {
@@ -42,15 +43,15 @@ a:hover {
.container { .container {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 0 1rem; padding: 0 clamp(0.75rem, 3vw, 1.5rem);
} }
.page-header { .page-header {
margin-bottom: 2rem; margin-bottom: clamp(1rem, 3vw, 2rem);
} }
.page-header h1 { .page-header h1 {
font-size: 1.75rem; font-size: clamp(1.25rem, 4vw, 1.75rem);
font-weight: 700; font-weight: 700;
} }
@@ -59,7 +60,7 @@ a:hover {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 1.5rem; padding: clamp(0.75rem, 3vw, 1.5rem);
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
@@ -71,10 +72,15 @@ a:hover {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: none; border: none;
border-radius: var(--radius); border-radius: var(--radius);
font-size: 0.875rem; font-size: clamp(0.8rem, 2vw, 0.875rem);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
touch-action: manipulation;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
} }
.btn-primary { .btn-primary {
@@ -228,3 +234,19 @@ a:hover {
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ── Responsive table ── */
@media (max-width: 480px) {
.table th, .table td {
padding: 0.4rem 0.35rem;
font-size: 0.78rem;
}
}
/* ── Touch-friendly form inputs ── */
@media (max-width: 768px) {
.form-input {
font-size: 16px; /* Prevents iOS zoom on focus */
padding: 0.6rem 0.75rem;
}
}

View File

@@ -0,0 +1,281 @@
<template>
<div class="ds-selector" ref="selectorRef">
<button class="ds-trigger" aria-label="Réglages d'affichage" @click="isOpen = !isOpen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<Transition name="ds-drop">
<div v-if="isOpen" class="ds-dropdown">
<h4 class="ds-title">Affichage</h4>
<!-- Font size -->
<div class="ds-section">
<span class="ds-label">Taille texte</span>
<div class="ds-toggle-group">
<button v-for="s in fontSizes" :key="s.value"
class="ds-toggle" :class="{ 'ds-toggle--active': currentFontSize === s.value }"
@click="setFontSize(s.value)">
{{ s.label }}
</button>
</div>
</div>
<!-- Chart density -->
<div class="ds-section">
<span class="ds-label">Densite graphiques</span>
<div class="ds-toggle-group">
<button v-for="d in densities" :key="d.value"
class="ds-toggle" :class="{ 'ds-toggle--active': currentDensity === d.value }"
@click="setDensity(d.value)">
{{ d.label }}
</button>
</div>
</div>
<!-- Palette -->
<div class="ds-section">
<span class="ds-label">Ambiance</span>
<div class="ds-palette-grid">
<button v-for="p in palettes" :key="p.name"
class="ds-palette-btn" :class="{ 'ds-palette-btn--active': currentPalette === p.name }"
@click="setPalette(p.name)">
<span class="ds-palette-dots">
<span class="ds-dot" :style="{ background: p.primary }"></span>
<span class="ds-dot" :style="{ background: p.accent }"></span>
</span>
<span class="ds-palette-name">{{ p.label }}</span>
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
const selectorRef = ref<HTMLElement>()
const isOpen = ref(false)
// ── Font size ──
const fontSizes = [
{ label: 'A-', value: 'small' },
{ label: 'A', value: 'normal' },
{ label: 'A+', value: 'large' },
]
const currentFontSize = ref('normal')
function setFontSize(size: string) {
currentFontSize.value = size
if (import.meta.client) {
localStorage.setItem('sej-fontSize', size)
const map: Record<string, string> = { small: '14px', normal: '16px', large: '18px' }
document.documentElement.style.fontSize = map[size] || '16px'
}
}
// ── Chart density ──
const densities = [
{ label: 'Compact', value: 'compact' },
{ label: 'Normal', value: 'normal' },
{ label: 'Large', value: 'large' },
]
const currentDensity = ref('normal')
function setDensity(d: string) {
currentDensity.value = d
if (import.meta.client) {
localStorage.setItem('sej-density', d)
const map: Record<string, string> = { compact: '0.85', normal: '1', large: '1.15' }
document.documentElement.style.setProperty('--chart-scale', map[d] || '1')
}
}
// ── Palette (color ambiance) ──
const palettes = [
{ name: 'eau', label: 'Eau', primary: '#2563eb', accent: '#059669' },
{ name: 'terre', label: 'Terre', primary: '#92400e', accent: '#d97706' },
{ name: 'foret', label: 'Foret', primary: '#166534', accent: '#65a30d' },
{ name: 'ardoise', label: 'Ardoise', primary: '#475569', accent: '#64748b' },
]
const currentPalette = ref('eau')
function setPalette(name: string) {
currentPalette.value = name
const p = palettes.find(x => x.name === name)
if (!p || !import.meta.client) return
localStorage.setItem('sej-palette', name)
const root = document.documentElement.style
root.setProperty('--color-primary', p.primary)
root.setProperty('--color-primary-dark', darken(p.primary))
root.setProperty('--color-secondary', p.accent)
}
function darken(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
const f = 0.82
return `#${Math.round(r * f).toString(16).padStart(2, '0')}${Math.round(g * f).toString(16).padStart(2, '0')}${Math.round(b * f).toString(16).padStart(2, '0')}`
}
// ── Init from localStorage ──
onMounted(() => {
if (!import.meta.client) return
const savedFont = localStorage.getItem('sej-fontSize')
if (savedFont) setFontSize(savedFont)
const savedDensity = localStorage.getItem('sej-density')
if (savedDensity) setDensity(savedDensity)
const savedPalette = localStorage.getItem('sej-palette')
if (savedPalette) setPalette(savedPalette)
})
// ── Click outside to close ──
function onClickOutside(e: MouseEvent) {
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
isOpen.value = false
}
}
onMounted(() => document.addEventListener('click', onClickOutside))
onUnmounted(() => document.removeEventListener('click', onClickOutside))
</script>
<style scoped>
.ds-selector { position: relative; }
.ds-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 6px;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
transition: all 0.15s;
}
.ds-trigger:hover {
color: var(--color-text);
background: var(--color-bg);
}
.ds-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
width: 240px;
padding: 0.75rem;
border-radius: 10px;
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: 0 8px 24px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
display: flex;
flex-direction: column;
gap: 0.65rem;
z-index: 100;
}
.ds-title {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-text-muted);
margin: 0;
}
.ds-section {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.ds-label {
font-size: 0.68rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ds-toggle-group {
display: flex;
gap: 0.2rem;
background: var(--color-bg);
border-radius: 6px;
padding: 2px;
}
.ds-toggle {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0.3rem 0.4rem;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 500;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
transition: all 0.15s;
}
.ds-toggle:hover { color: var(--color-text); }
.ds-toggle--active {
background: var(--color-primary);
color: white;
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.25);
}
.ds-palette-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.3rem;
}
.ds-palette-btn {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.4rem;
border-radius: 6px;
background: var(--color-bg);
border: 1.5px solid transparent;
cursor: pointer;
transition: all 0.15s;
}
.ds-palette-btn:hover { border-color: var(--color-border); }
.ds-palette-btn--active {
border-color: var(--color-primary);
background: white;
}
.ds-palette-dots {
display: flex;
gap: 2px;
}
.ds-dot {
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
}
.ds-palette-name {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text);
}
/* Transition */
.ds-drop-enter-active { transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
.ds-drop-leave-active { transition: all 0.12s ease; }
.ds-drop-enter-from, .ds-drop-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.96);
}
</style>

View File

@@ -1,6 +1,12 @@
<template> <template>
<div class="overlay-chart"> <div class="overlay-chart">
<svg :viewBox="`0 0 ${svgW} ${svgH}`" preserveAspectRatio="xMidYMid meet"> <svg :viewBox="`0 0 ${svgW} ${svgH}`" preserveAspectRatio="xMidYMid meet">
<defs>
<clipPath id="overlay-clip">
<rect :x="margin.left" :y="margin.top" :width="plotW" :height="plotH" />
</clipPath>
</defs>
<!-- Grid --> <!-- Grid -->
<g> <g>
<line <line
@@ -17,6 +23,8 @@
/> />
</g> </g>
<!-- Clipped curves -->
<g clip-path="url(#overlay-clip)">
<!-- 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.5" opacity="0.4" /> <path :d="getVotePath(vote, 1)" fill="none" stroke="#3b82f6" stroke-width="1.5" opacity="0.4" />
@@ -28,6 +36,7 @@
<path :d="getVotePath(medianVote, 1)" fill="none" stroke="#1e40af" stroke-width="4" /> <path :d="getVotePath(medianVote, 1)" fill="none" stroke="#1e40af" stroke-width="4" />
<path :d="getVotePath(medianVote, 2)" fill="none" stroke="#991b1b" stroke-width="4" /> <path :d="getVotePath(medianVote, 2)" fill="none" stroke="#991b1b" stroke-width="4" />
</g> </g>
</g>
<!-- Axis labels --> <!-- Axis labels -->
<text :x="svgW / 2" :y="svgH - 2" text-anchor="middle" font-size="11" fill="#64748b"> <text :x="svgW / 2" :y="svgH - 2" text-anchor="middle" font-size="11" fill="#64748b">

View File

@@ -3,19 +3,26 @@
<header class="app-header"> <header class="app-header">
<div class="container header-inner"> <div class="container header-inner">
<NuxtLink to="/" class="logo">SejeteralO</NuxtLink> <NuxtLink to="/" class="logo">SejeteralO</NuxtLink>
<nav class="header-nav"> <div class="header-right">
<NuxtLink to="/">Accueil</NuxtLink> <template v-if="authStore.isAuthenticated && authStore.isAdmin">
<template v-if="authStore.isAuthenticated">
<NuxtLink v-if="authStore.isSuperAdmin" to="/admin">Super Admin</NuxtLink>
<NuxtLink <NuxtLink
v-else-if="authStore.isAdmin && authStore.communeSlug" v-if="authStore.isSuperAdmin"
to="/admin"
class="header-link"
>
Administration
</NuxtLink>
<NuxtLink
v-else-if="authStore.communeSlug"
:to="`/admin/communes/${authStore.communeSlug}`" :to="`/admin/communes/${authStore.communeSlug}`"
class="header-link"
> >
Gestion commune Gestion commune
</NuxtLink> </NuxtLink>
<button class="btn btn-secondary btn-sm" @click="logout">Déconnexion</button> <button class="btn btn-secondary btn-sm" @click="logout">Deconnexion</button>
</template> </template>
</nav> <DisplaySettings />
</div>
</div> </div>
</header> </header>
<main class="app-main container"> <main class="app-main container">
@@ -23,7 +30,7 @@
</main> </main>
<footer class="app-footer"> <footer class="app-footer">
<div class="container"> <div class="container">
SejeteralO Outil de démocratie participative pour la tarification de l'eau SejeteralO Outil de democratie participative pour la tarification de l'eau
</div> </div>
</footer> </footer>
</div> </div>
@@ -50,6 +57,9 @@ function logout() {
background: var(--color-surface); background: var(--color-surface);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
padding: 0.75rem 0; padding: 0.75rem 0;
position: sticky;
top: 0;
z-index: 40;
} }
.header-inner { .header-inner {
@@ -59,21 +69,25 @@ function logout() {
} }
.logo { .logo {
font-size: 1.25rem; font-size: clamp(1.05rem, 3vw, 1.25rem);
font-weight: 700; font-weight: 700;
color: var(--color-primary); color: var(--color-primary);
} }
.logo:hover { text-decoration: none; }
.logo:hover { .header-right {
text-decoration: none;
}
.header-nav {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: clamp(0.5rem, 2vw, 1rem);
} }
.header-link {
font-size: clamp(0.75rem, 2vw, 0.85rem);
white-space: nowrap;
color: var(--color-text-muted);
}
.header-link:hover { color: var(--color-primary); text-decoration: none; }
.btn-sm { .btn-sm {
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
font-size: 0.8rem; font-size: 0.8rem;
@@ -81,8 +95,8 @@ function logout() {
.app-main { .app-main {
flex: 1; flex: 1;
padding-top: 2rem; padding-top: clamp(1rem, 3vw, 2rem);
padding-bottom: 2rem; padding-bottom: clamp(1rem, 3vw, 2rem);
} }
.app-footer { .app-footer {

View File

@@ -2,35 +2,43 @@
<div> <div>
<div class="page-header"> <div class="page-header">
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink> <NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">&larr; {{ slug }}</NuxtLink>
<h1>Paramètres tarifs</h1> <h1>Parametres tarifs</h1>
</div> </div>
<div v-if="saved" class="alert alert-success">Paramètres enregistrés.</div> <div v-if="saved" class="alert alert-success">Parametres enregistres.</div>
<div v-if="published" class="alert alert-success">Courbe publiee avec succes (p0 = {{ publishedP0.toFixed(3) }} /m3).</div>
<div v-if="error" class="alert alert-error">{{ error }}</div> <div v-if="error" class="alert alert-error">{{ error }}</div>
<div class="card" style="max-width: 600px;"> <div class="card" style="max-width: 600px; margin-bottom: 1.5rem;">
<form @submit.prevent="save"> <form @submit.prevent="save">
<div class="form-group"> <div class="form-group">
<label>Recettes cibles ()</label> <label>Recettes cibles ()</label>
<input v-model.number="form.recettes" type="number" class="form-input" step="1000" min="0" /> <input v-model.number="form.recettes" type="number" class="form-input" step="1000" min="0" />
</div> </div>
<div class="grid grid-2">
<div class="form-group"> <div class="form-group">
<label>Abonnement RP/PRO ()</label> <label>Abonnement ()</label>
<input v-model.number="form.abop" type="number" class="form-input" step="1" min="0" /> <input v-model.number="form.abop" type="number" class="form-input" step="1" min="0" />
</div> </div>
<div class="form-group"> <div class="form-group" style="margin-bottom: 1rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" v-model="form.differentiated_tariff" />
Tarif differencie RS / RP / PRO
</label>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-top: 0.25rem;">
Si coche, un abonnement different est applique aux residences secondaires (RS).
</p>
</div>
<div v-if="form.differentiated_tariff" class="form-group">
<label>Abonnement RS ()</label> <label>Abonnement RS ()</label>
<input v-model.number="form.abos" type="number" class="form-input" step="1" min="0" /> <input v-model.number="form.abos" type="number" class="form-input" step="1" min="0" />
</div> </div>
</div>
<div class="grid grid-2"> <div class="grid grid-2">
<div class="form-group"> <div class="form-group">
<label>Prix max/m³ ()</label> <label>Prix max/m3 ()</label>
<input v-model.number="form.pmax" type="number" class="form-input" step="0.5" min="0" /> <input v-model.number="form.pmax" type="number" class="form-input" step="0.5" min="0" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Volume max (m³)</label> <label>Volume max (m3)</label>
<input v-model.number="form.vmax" type="number" class="form-input" step="100" min="0" /> <input v-model.number="form.vmax" type="number" class="form-input" step="100" min="0" />
</div> </div>
</div> </div>
@@ -39,6 +47,58 @@
</button> </button>
</form> </form>
</div> </div>
<!--
COURBE DE REFERENCE Editeur + Publier
-->
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin-bottom: 0.5rem;">Courbe de reference</h3>
<p style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 1rem;">
Ajustez les parametres de la courbe Bezier, puis publiez-la. C'est la courbe initiale que les citoyens voient.
</p>
<!-- Curve params sliders -->
<div class="curve-params">
<div class="slider-group">
<label>v<sub>inf</sub> <span class="slider-val">{{ curve.vinf.toFixed(0) }} m3</span></label>
<input type="range" v-model.number="curve.vinf" :min="50" :max="form.vmax - 100" step="10" class="slider" />
</div>
<div class="slider-group">
<label>a <span class="slider-val">{{ curve.a.toFixed(2) }}</span></label>
<input type="range" v-model.number="curve.a" min="0" max="1" step="0.01" class="slider" />
</div>
<div class="slider-group">
<label>b <span class="slider-val">{{ curve.b.toFixed(2) }}</span></label>
<input type="range" v-model.number="curve.b" min="0" max="1" step="0.01" class="slider" />
</div>
<div class="slider-group">
<label>c <span class="slider-val">{{ curve.c.toFixed(2) }}</span></label>
<input type="range" v-model.number="curve.c" min="0" max="1" step="0.01" class="slider" />
</div>
<div class="slider-group">
<label>d <span class="slider-val">{{ curve.d.toFixed(2) }}</span></label>
<input type="range" v-model.number="curve.d" min="0" max="1" step="0.01" class="slider" />
</div>
<div class="slider-group">
<label>e <span class="slider-val">{{ curve.e.toFixed(2) }}</span></label>
<input type="range" v-model.number="curve.e" min="0" max="1" step="0.01" class="slider" />
</div>
</div>
<p v-if="curveP0 !== null" style="margin: 0.75rem 0; font-size: 0.9rem;">
<strong>p0 calcule :</strong> {{ curveP0.toFixed(3) }} €/m3
</p>
<div v-if="currentPublished" style="margin-bottom: 0.75rem; padding: 0.5rem 0.75rem; background: #eff6ff; border-radius: 6px; font-size: 0.82rem; color: #1e40af;">
Courbe actuellement publiee : vinf={{ currentPublished.vinf?.toFixed(0) }}, p0={{ currentPublished.p0?.toFixed(3) }} €/m3
<span style="color: #64748b;">({{ currentPublished.at }})</span>
</div>
<button class="btn btn-primary" @click="publishCurve" :disabled="publishing" style="background: #059669;">
<span v-if="publishing">Publication...</span>
<span v-else>Publier la courbe</span>
</button>
</div>
</div> </div>
</template> </template>
@@ -49,15 +109,56 @@ const route = useRoute()
const api = useApi() const api = useApi()
const slug = route.params.slug as string const slug = route.params.slug as string
const form = reactive({ recettes: 75000, abop: 100, abos: 100, pmax: 20, vmax: 2100 }) const form = reactive({ recettes: 75000, abop: 100, abos: 100, pmax: 20, vmax: 2100, differentiated_tariff: false })
const curve = reactive({ vinf: 400, a: 0.5, b: 0.5, c: 0.5, d: 0.5, e: 0.5 })
const loading = ref(false) const loading = ref(false)
const saved = ref(false) const saved = ref(false)
const published = ref(false)
const publishing = ref(false)
const error = ref('') const error = ref('')
const curveP0 = ref<number | null>(null)
const publishedP0 = ref(0)
const currentPublished = ref<{ vinf: number; p0: number; at: string } | null>(null)
let computeTimeout: ReturnType<typeof setTimeout> | null = null
watch(curve, () => {
if (computeTimeout) clearTimeout(computeTimeout)
computeTimeout = setTimeout(async () => {
try {
const result = await api.post<any>('/tariff/compute', {
commune_slug: slug, vinf: curve.vinf,
a: curve.a, b: curve.b, c: curve.c, d: curve.d, e: curve.e,
})
curveP0.value = result.p0
} catch {}
}, 200)
}, { deep: true })
onMounted(async () => { onMounted(async () => {
try { try {
const params = await api.get<typeof form>(`/communes/${slug}/params`) const params = await api.get<any>(`/communes/${slug}/params`)
Object.assign(form, params) Object.assign(form, params)
// Load published curve if exists
if (params.published_vinf != null) {
curve.vinf = params.published_vinf
curve.a = params.published_a
curve.b = params.published_b
curve.c = params.published_c
curve.d = params.published_d
curve.e = params.published_e
currentPublished.value = {
vinf: params.published_vinf,
p0: params.published_p0,
at: new Date(params.published_at).toLocaleDateString('fr-FR'),
}
}
// Trigger initial p0 compute
const result = await api.post<any>('/tariff/compute', {
commune_slug: slug, vinf: curve.vinf,
a: curve.a, b: curve.b, c: curve.c, d: curve.d, e: curve.e,
})
curveP0.value = result.p0
} catch {} } catch {}
}) })
@@ -74,4 +175,54 @@ async function save() {
loading.value = false loading.value = false
} }
} }
async function publishCurve() {
publishing.value = true
published.value = false
error.value = ''
try {
const result = await api.post<any>(`/communes/${slug}/params/publish`, {
vinf: curve.vinf, a: curve.a, b: curve.b,
c: curve.c, d: curve.d, e: curve.e,
})
publishedP0.value = result.published_p0
published.value = true
currentPublished.value = {
vinf: result.published_vinf,
p0: result.published_p0,
at: new Date(result.published_at).toLocaleDateString('fr-FR'),
}
} catch (e: any) {
error.value = e.message
} finally {
publishing.value = false
}
}
</script> </script>
<style scoped>
.curve-params {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.5rem;
margin-bottom: 0.75rem;
}
@media (max-width: 600px) { .curve-params { grid-template-columns: 1fr; } }
.slider-group label {
display: flex;
justify-content: space-between;
font-size: 0.82rem;
font-weight: 500;
margin-bottom: 0.2rem;
}
.slider-val {
font-family: monospace;
color: var(--color-primary, #2563eb);
font-weight: 600;
}
.slider {
width: 100%;
accent-color: #2563eb;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,11 @@
<span v-else>Se connecter</span> <span v-else>Se connecter</span>
</button> </button>
</form> </form>
<!-- Dev credentials hint -->
<div v-if="isDev" style="margin-top: 1rem; padding: 0.75rem; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 6px; font-size: 0.8rem;">
<strong>Dev:</strong> superadmin@sejeteralo.fr / superadmin
</div>
</div> </div>
<p style="text-align: center; margin-top: 1rem;"> <p style="text-align: center; margin-top: 1rem;">
@@ -39,6 +44,7 @@ const email = ref('')
const password = ref('') const password = ref('')
const error = ref('') const error = ref('')
const loading = ref(false) const loading = ref(false)
const isDev = import.meta.dev
async function login() { async function login() {
error.value = '' error.value = ''

View File

@@ -22,6 +22,11 @@
<span v-else>Se connecter</span> <span v-else>Se connecter</span>
</button> </button>
</form> </form>
<!-- Dev credentials hint -->
<div v-if="isDev" style="margin-top: 1rem; padding: 0.75rem; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 6px; font-size: 0.8rem;">
<strong>Dev:</strong> saou@sejeteralo.fr / saou2024
</div>
</div> </div>
<p style="text-align: center; margin-top: 1rem;"> <p style="text-align: center; margin-top: 1rem;">
@@ -39,6 +44,7 @@ const email = ref('')
const password = ref('') const password = ref('')
const error = ref('') const error = ref('')
const loading = ref(false) const loading = ref(false)
const isDev = import.meta.dev
async function login() { async function login() {
error.value = '' error.value = ''

View File

@@ -277,7 +277,7 @@ export function computeImpacts(
c: number, c: number,
d: number, d: number,
e: number, e: number,
referenceVolumes: number[] = [30, 60, 90, 150, 300], referenceVolumes: number[] = [30, 60, 90, 150, 300, 600],
): { p0: number; impacts: ImpactRow[] } { ): { p0: number; impacts: ImpactRow[] } {
const p0 = computeP0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e) const p0 = computeP0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e)