Initial commit: SejeteralO water tarification platform
Full-stack app for participatory water pricing using Bezier curves. - Backend: FastAPI + SQLAlchemy + SQLite with JWT auth - Frontend: Nuxt 4 + TypeScript with interactive SVG editor - Math engine: cubic Bezier tarification with Cardano solver - Admin: commune management, household import, vote monitoring, CMS - Citizen: interactive curve editor, vote submission - Docker-compose deployment ready Includes fixes for: - Impact table snake_case/camelCase property mismatch - CMS content backend API + frontend editor (was stub) - Admin route protection middleware - Public content display on commune page - Vote confirmation page link fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Backend
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./sejeteralo.db
|
||||||
|
SECRET_KEY=change-me-in-production-with-a-real-secret-key
|
||||||
|
DEBUG=true
|
||||||
|
CORS_ORIGINS=["http://localhost:3000"]
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
NUXT_PUBLIC_API_BASE=http://localhost:8000/api/v1
|
||||||
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Jupyter
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
.nitro/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
backend/uploads/
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
29
Makefile
Normal file
29
Makefile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
.PHONY: install dev dev-backend dev-frontend test seed docker-up docker-down
|
||||||
|
|
||||||
|
# ── Development ──
|
||||||
|
|
||||||
|
install:
|
||||||
|
cd backend && python3 -m venv venv && . venv/bin/activate && pip install -r requirements.txt
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
dev: dev-backend dev-frontend
|
||||||
|
|
||||||
|
dev-backend:
|
||||||
|
cd backend && . venv/bin/activate && uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
|
dev-frontend:
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
test:
|
||||||
|
cd backend && . venv/bin/activate && python -m pytest tests/ -v
|
||||||
|
|
||||||
|
seed:
|
||||||
|
cd backend && . venv/bin/activate && python seed.py
|
||||||
|
|
||||||
|
# ── Docker ──
|
||||||
|
|
||||||
|
docker-up:
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
docker compose down
|
||||||
4
backend/.env.example
Normal file
4
backend/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
DATABASE_URL=sqlite+aiosqlite:///./sejeteralo.db
|
||||||
|
SECRET_KEY=change-me-in-production-with-a-real-secret-key
|
||||||
|
DEBUG=true
|
||||||
|
CORS_ORIGINS=["http://localhost:3000"]
|
||||||
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
117
backend/alembic.ini
Normal file
117
backend/alembic.ini
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||||
|
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
# version_path_separator = newline
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
54
backend/alembic/env.py
Normal file
54
backend/alembic/env.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import models # noqa: F401 - import to register models
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# Use sqlite for dev
|
||||||
|
config.set_main_option("sqlalchemy.url", "sqlite:///./sejeteralo.db")
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
130
backend/alembic/versions/25f534648ea7_initial_schema.py
Normal file
130
backend/alembic/versions/25f534648ea7_initial_schema.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 25f534648ea7
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-02-21 05:29:28.228738
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '25f534648ea7'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
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.create_table('admin_users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('hashed_password', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('full_name', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('role', sa.String(length=20), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_admin_users_email'), 'admin_users', ['email'], unique=True)
|
||||||
|
op.create_index(op.f('ix_admin_users_id'), 'admin_users', ['id'], unique=False)
|
||||||
|
op.create_table('communes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_communes_id'), 'communes', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_communes_slug'), 'communes', ['slug'], unique=True)
|
||||||
|
op.create_table('admin_commune',
|
||||||
|
sa.Column('admin_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('commune_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['admin_id'], ['admin_users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['commune_id'], ['communes.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('admin_id', 'commune_id')
|
||||||
|
)
|
||||||
|
op.create_table('commune_contents',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('commune_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('slug', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('body_markdown', sa.Text(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['commune_id'], ['communes.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('commune_id', 'slug', name='uq_content_commune_slug')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_commune_contents_id'), 'commune_contents', ['id'], unique=False)
|
||||||
|
op.create_table('households',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('commune_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('identifier', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=10), nullable=False),
|
||||||
|
sa.Column('volume_m3', sa.Float(), nullable=False),
|
||||||
|
sa.Column('price_paid_eur', sa.Float(), nullable=True),
|
||||||
|
sa.Column('auth_code', sa.String(length=8), nullable=False),
|
||||||
|
sa.Column('has_voted', sa.Boolean(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['commune_id'], ['communes.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('commune_id', 'identifier', name='uq_household_commune_identifier')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_households_auth_code'), 'households', ['auth_code'], unique=True)
|
||||||
|
op.create_index(op.f('ix_households_id'), 'households', ['id'], unique=False)
|
||||||
|
op.create_table('tariff_params',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('commune_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('abop', sa.Float(), nullable=True),
|
||||||
|
sa.Column('abos', sa.Float(), nullable=True),
|
||||||
|
sa.Column('recettes', sa.Float(), nullable=True),
|
||||||
|
sa.Column('pmax', sa.Float(), nullable=True),
|
||||||
|
sa.Column('vmax', sa.Float(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['commune_id'], ['communes.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('commune_id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_tariff_params_id'), 'tariff_params', ['id'], unique=False)
|
||||||
|
op.create_table('votes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('commune_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('household_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('vinf', sa.Float(), nullable=False),
|
||||||
|
sa.Column('a', sa.Float(), nullable=False),
|
||||||
|
sa.Column('b', sa.Float(), nullable=False),
|
||||||
|
sa.Column('c', sa.Float(), nullable=False),
|
||||||
|
sa.Column('d', sa.Float(), nullable=False),
|
||||||
|
sa.Column('e', sa.Float(), nullable=False),
|
||||||
|
sa.Column('computed_p0', sa.Float(), nullable=True),
|
||||||
|
sa.Column('submitted_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['commune_id'], ['communes.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['household_id'], ['households.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_votes_id'), 'votes', ['id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_votes_id'), table_name='votes')
|
||||||
|
op.drop_table('votes')
|
||||||
|
op.drop_index(op.f('ix_tariff_params_id'), table_name='tariff_params')
|
||||||
|
op.drop_table('tariff_params')
|
||||||
|
op.drop_index(op.f('ix_households_id'), table_name='households')
|
||||||
|
op.drop_index(op.f('ix_households_auth_code'), table_name='households')
|
||||||
|
op.drop_table('households')
|
||||||
|
op.drop_index(op.f('ix_commune_contents_id'), table_name='commune_contents')
|
||||||
|
op.drop_table('commune_contents')
|
||||||
|
op.drop_table('admin_commune')
|
||||||
|
op.drop_index(op.f('ix_communes_slug'), table_name='communes')
|
||||||
|
op.drop_index(op.f('ix_communes_id'), table_name='communes')
|
||||||
|
op.drop_table('communes')
|
||||||
|
op.drop_index(op.f('ix_admin_users_id'), table_name='admin_users')
|
||||||
|
op.drop_index(op.f('ix_admin_users_email'), table_name='admin_users')
|
||||||
|
op.drop_table('admin_users')
|
||||||
|
# ### end Alembic commands ###
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
19
backend/app/config.py
Normal file
19
backend/app/config.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
APP_NAME: str = "SejeteralO"
|
||||||
|
DEBUG: bool = True
|
||||||
|
DATABASE_URL: str = "sqlite+aiosqlite:///./sejeteralo.db"
|
||||||
|
SECRET_KEY: str = "change-me-in-production-with-a-real-secret-key"
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
ADMIN_TOKEN_EXPIRE_HOURS: int = 24
|
||||||
|
CITIZEN_TOKEN_EXPIRE_HOURS: int = 4
|
||||||
|
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||||
|
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:3001", "http://localhost:3009"]
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
21
backend/app/database.py
Normal file
21
backend/app/database.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with async_session() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
23
backend/app/engine/__init__.py
Normal file
23
backend/app/engine/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from app.engine.integrals import compute_integrals
|
||||||
|
from app.engine.pricing import (
|
||||||
|
HouseholdData,
|
||||||
|
TariffResult,
|
||||||
|
compute_p0,
|
||||||
|
compute_tariff,
|
||||||
|
compute_impacts,
|
||||||
|
)
|
||||||
|
from app.engine.current_model import compute_linear_tariff, LinearTariffResult
|
||||||
|
from app.engine.median import VoteParams, compute_median
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"compute_integrals",
|
||||||
|
"HouseholdData",
|
||||||
|
"TariffResult",
|
||||||
|
"compute_p0",
|
||||||
|
"compute_tariff",
|
||||||
|
"compute_impacts",
|
||||||
|
"compute_linear_tariff",
|
||||||
|
"LinearTariffResult",
|
||||||
|
"VoteParams",
|
||||||
|
"compute_median",
|
||||||
|
]
|
||||||
66
backend/app/engine/current_model.py
Normal file
66
backend/app/engine/current_model.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Current (linear) pricing model.
|
||||||
|
|
||||||
|
Ported from eau.py:256-354 (CurrentModel).
|
||||||
|
Pure Python + numpy, no matplotlib.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.engine.pricing import HouseholdData
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LinearTariffResult:
|
||||||
|
"""Result of the linear tariff computation."""
|
||||||
|
p0: float # flat price per m³
|
||||||
|
curve_volumes: list[float]
|
||||||
|
curve_bills_rp: list[float]
|
||||||
|
curve_bills_rs: list[float]
|
||||||
|
curve_price_m3_rp: list[float]
|
||||||
|
curve_price_m3_rs: list[float]
|
||||||
|
|
||||||
|
|
||||||
|
def compute_linear_tariff(
|
||||||
|
households: list[HouseholdData],
|
||||||
|
recettes: float,
|
||||||
|
abop: float,
|
||||||
|
abos: float,
|
||||||
|
vmax: float = 2100,
|
||||||
|
nbpts: int = 200,
|
||||||
|
) -> LinearTariffResult:
|
||||||
|
"""
|
||||||
|
Compute the linear (current) pricing model.
|
||||||
|
|
||||||
|
p0 = (recettes - Σ abo) / Σ volume
|
||||||
|
"""
|
||||||
|
total_abo = 0.0
|
||||||
|
total_volume = 0.0
|
||||||
|
|
||||||
|
for h in households:
|
||||||
|
abo = abos if h.status == "RS" else abop
|
||||||
|
total_abo += abo
|
||||||
|
total_volume += max(h.volume_m3, 1e-5)
|
||||||
|
|
||||||
|
if total_abo >= recettes or total_volume == 0:
|
||||||
|
p0 = 0.0
|
||||||
|
else:
|
||||||
|
p0 = (recettes - total_abo) / total_volume
|
||||||
|
|
||||||
|
# Generate curves
|
||||||
|
import numpy as np
|
||||||
|
vv = np.linspace(1e-5, vmax, nbpts)
|
||||||
|
|
||||||
|
bills_rp = abop + p0 * vv
|
||||||
|
bills_rs = abos + p0 * vv
|
||||||
|
price_m3_rp = abop / vv + p0
|
||||||
|
price_m3_rs = abos / vv + p0
|
||||||
|
|
||||||
|
return LinearTariffResult(
|
||||||
|
p0=p0,
|
||||||
|
curve_volumes=vv.tolist(),
|
||||||
|
curve_bills_rp=bills_rp.tolist(),
|
||||||
|
curve_bills_rs=bills_rs.tolist(),
|
||||||
|
curve_price_m3_rp=price_m3_rp.tolist(),
|
||||||
|
curve_price_m3_rs=price_m3_rs.tolist(),
|
||||||
|
)
|
||||||
118
backend/app/engine/integrals.py
Normal file
118
backend/app/engine/integrals.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Integral computation for Bézier tariff curves.
|
||||||
|
|
||||||
|
Ported from eau.py:169-211 (NewModel.computeIntegrals).
|
||||||
|
Pure Python + numpy, no matplotlib.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def compute_integrals(
|
||||||
|
volume: float,
|
||||||
|
vinf: float,
|
||||||
|
vmax: float,
|
||||||
|
pmax: float,
|
||||||
|
a: float,
|
||||||
|
b: float,
|
||||||
|
c: float,
|
||||||
|
d: float,
|
||||||
|
e: float,
|
||||||
|
) -> tuple[float, float, float]:
|
||||||
|
"""
|
||||||
|
Compute (alpha1, alpha2, beta2) for a given consumption volume.
|
||||||
|
|
||||||
|
The total bill for a household consuming `volume` m³ is:
|
||||||
|
bill = abo + alpha1 * p0 + alpha2 * p0 + beta2
|
||||||
|
|
||||||
|
where p0 is the inflection price (computed separately to balance revenue).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
volume: consumption in m³ for this household
|
||||||
|
vinf: inflection volume separating the two tiers
|
||||||
|
vmax: maximum volume (price = pmax at this volume)
|
||||||
|
pmax: maximum price per m³
|
||||||
|
a, b: shape parameters for tier 1 Bézier curve
|
||||||
|
c, d, e: shape parameters for tier 2 Bézier curve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(alpha1, alpha2, beta2) tuple
|
||||||
|
"""
|
||||||
|
if volume <= vinf:
|
||||||
|
# Tier 1 only
|
||||||
|
T = _solve_tier1_t(volume, vinf, b)
|
||||||
|
alpha1 = _compute_alpha1(T, vinf, a, b)
|
||||||
|
return alpha1, 0.0, 0.0
|
||||||
|
else:
|
||||||
|
# Full tier 1 (T=1) + partial tier 2
|
||||||
|
alpha1 = _compute_alpha1(1.0, vinf, a, b)
|
||||||
|
|
||||||
|
# Tier 2
|
||||||
|
wmax = vmax - vinf
|
||||||
|
T = _solve_tier2_t(volume - vinf, wmax, c, d)
|
||||||
|
|
||||||
|
uu = _compute_uu(T, c, d, e)
|
||||||
|
alpha2 = (volume - vinf) - 3 * uu * wmax
|
||||||
|
beta2 = 3 * pmax * wmax * uu
|
||||||
|
return alpha1, alpha2, beta2
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_tier1_t(volume: float, vinf: float, b: float) -> float:
|
||||||
|
"""Find T such that v(T) = volume for tier 1."""
|
||||||
|
if volume == 0:
|
||||||
|
return 0.0
|
||||||
|
if volume >= vinf:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
# Solve: vinf * [(1 - 3b) * T³ + 3b * T²] = volume
|
||||||
|
# => (1-3b) * T³ + 3b * T² - volume/vinf = 0
|
||||||
|
p = [1 - 3 * b, 3 * b, 0, -volume / vinf]
|
||||||
|
roots = np.roots(p)
|
||||||
|
roots = np.unique(roots)
|
||||||
|
real_roots = np.real(roots[np.isreal(roots)])
|
||||||
|
mask = (real_roots <= 1.0) & (real_roots >= 0.0)
|
||||||
|
return float(real_roots[mask][0])
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_tier2_t(w: float, wmax: float, c: float, d: float) -> float:
|
||||||
|
"""Find T such that w(T) = w for tier 2, where w = volume - vinf."""
|
||||||
|
if w == 0:
|
||||||
|
return 0.0
|
||||||
|
if w >= wmax:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
# Solve: wmax * [(3(c+d-cd)-2)*T³ + 3(1-2c-d+cd)*T² + 3c*T] = w
|
||||||
|
p = [
|
||||||
|
3 * (c + d - c * d) - 2,
|
||||||
|
3 * (1 - 2 * c - d + c * d),
|
||||||
|
3 * c,
|
||||||
|
-w / wmax,
|
||||||
|
]
|
||||||
|
roots = np.roots(p)
|
||||||
|
roots = np.unique(roots)
|
||||||
|
real_roots = np.real(roots[np.isreal(roots)])
|
||||||
|
mask = (real_roots <= 1.0 + 1e-10) & (real_roots >= -1e-10)
|
||||||
|
if not mask.any():
|
||||||
|
# Fallback: closest root to [0,1]
|
||||||
|
return float(np.clip(np.real(roots[0]), 0.0, 1.0))
|
||||||
|
return float(np.clip(real_roots[mask][0], 0.0, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_alpha1(T: float, vinf: float, a: float, b: float) -> float:
|
||||||
|
"""Compute alpha1 coefficient for tier 1."""
|
||||||
|
return 3 * vinf * (
|
||||||
|
T**6 / 6 * (-9 * a * b + 3 * a + 6 * b - 2)
|
||||||
|
+ T**5 / 5 * (24 * a * b - 6 * a - 13 * b + 3)
|
||||||
|
+ 3 * T**4 / 4 * (-7 * a * b + a + 2 * b)
|
||||||
|
+ T**3 / 3 * 6 * a * b
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_uu(T: float, c: float, d: float, e: float) -> float:
|
||||||
|
"""Compute the uu intermediate value for tier 2."""
|
||||||
|
return (
|
||||||
|
(-3 * c * d + 9 * e * c * d + 3 * c - 9 * e * c + 3 * d - 9 * e * d + 6 * e - 2) * T**6 / 6
|
||||||
|
+ (2 * c * d - 15 * e * c * d - 4 * c + 21 * e * c - 2 * d + 15 * e * d - 12 * e + 2) * T**5 / 5
|
||||||
|
+ (6 * e * c * d + c - 15 * e * c - 6 * e * d + 6 * e) * T**4 / 4
|
||||||
|
+ (3 * e * c) * T**3 / 3
|
||||||
|
)
|
||||||
48
backend/app/engine/median.py
Normal file
48
backend/app/engine/median.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Median computation for vote parameters.
|
||||||
|
|
||||||
|
Computes the element-wise median of (vinf, a, b, c, d, e) across all active votes.
|
||||||
|
This parametric median is chosen over geometric median because:
|
||||||
|
- It's transparent and politically explainable
|
||||||
|
- The result is itself a valid set of Bézier parameters
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VoteParams:
|
||||||
|
"""The 6 citizen-adjustable parameters."""
|
||||||
|
vinf: float
|
||||||
|
a: float
|
||||||
|
b: float
|
||||||
|
c: float
|
||||||
|
d: float
|
||||||
|
e: float
|
||||||
|
|
||||||
|
|
||||||
|
def compute_median(votes: list[VoteParams]) -> VoteParams | None:
|
||||||
|
"""
|
||||||
|
Compute element-wise median of vote parameters.
|
||||||
|
|
||||||
|
Returns None if no votes provided.
|
||||||
|
"""
|
||||||
|
if not votes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
vinfs = [v.vinf for v in votes]
|
||||||
|
a_s = [v.a for v in votes]
|
||||||
|
b_s = [v.b for v in votes]
|
||||||
|
c_s = [v.c for v in votes]
|
||||||
|
d_s = [v.d for v in votes]
|
||||||
|
e_s = [v.e for v in votes]
|
||||||
|
|
||||||
|
return VoteParams(
|
||||||
|
vinf=float(np.median(vinfs)),
|
||||||
|
a=float(np.median(a_s)),
|
||||||
|
b=float(np.median(b_s)),
|
||||||
|
c=float(np.median(c_s)),
|
||||||
|
d=float(np.median(d_s)),
|
||||||
|
e=float(np.median(e_s)),
|
||||||
|
)
|
||||||
196
backend/app/engine/pricing.py
Normal file
196
backend/app/engine/pricing.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
Pricing computation for Bézier tariff model.
|
||||||
|
|
||||||
|
Ported from eau.py:120-167 (NewModel.updateComputation).
|
||||||
|
Pure Python + numpy, no matplotlib.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.engine.integrals import compute_integrals
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HouseholdData:
|
||||||
|
"""Minimal household data needed for computation."""
|
||||||
|
volume_m3: float
|
||||||
|
status: str # "RS", "RP", or "PRO"
|
||||||
|
price_paid_eur: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TariffResult:
|
||||||
|
"""Result of a full tariff computation."""
|
||||||
|
p0: float
|
||||||
|
curve_volumes: list[float]
|
||||||
|
curve_prices_m3: list[float]
|
||||||
|
curve_bills_rp: list[float]
|
||||||
|
curve_bills_rs: list[float]
|
||||||
|
household_bills: list[float] # projected bill for each household
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImpactRow:
|
||||||
|
"""Price impact for a specific volume level."""
|
||||||
|
volume: float
|
||||||
|
old_price: float
|
||||||
|
new_price_rp: float
|
||||||
|
new_price_rs: float
|
||||||
|
|
||||||
|
|
||||||
|
def compute_p0(
|
||||||
|
households: list[HouseholdData],
|
||||||
|
recettes: float,
|
||||||
|
abop: float,
|
||||||
|
abos: float,
|
||||||
|
vinf: float,
|
||||||
|
vmax: float,
|
||||||
|
pmax: float,
|
||||||
|
a: float,
|
||||||
|
b: float,
|
||||||
|
c: float,
|
||||||
|
d: float,
|
||||||
|
e: float,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Compute p0 (inflection price) that balances total revenue.
|
||||||
|
|
||||||
|
p0 = (R - Σ(abo + β₂)) / Σ(α₁ + α₂)
|
||||||
|
"""
|
||||||
|
total_abo = 0.0
|
||||||
|
total_alpha = 0.0
|
||||||
|
total_beta = 0.0
|
||||||
|
|
||||||
|
for h in households:
|
||||||
|
abo = abos if h.status == "RS" else abop
|
||||||
|
total_abo += abo
|
||||||
|
|
||||||
|
vol = max(h.volume_m3, 1e-5) # avoid div by 0
|
||||||
|
alpha1, alpha2, beta2 = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
|
||||||
|
total_alpha += alpha1 + alpha2
|
||||||
|
total_beta += beta2
|
||||||
|
|
||||||
|
if total_abo >= recettes:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
if total_alpha == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return (recettes - total_abo - total_beta) / total_alpha
|
||||||
|
|
||||||
|
|
||||||
|
def compute_tariff(
|
||||||
|
households: list[HouseholdData],
|
||||||
|
recettes: float,
|
||||||
|
abop: float,
|
||||||
|
abos: float,
|
||||||
|
vinf: float,
|
||||||
|
vmax: float,
|
||||||
|
pmax: float,
|
||||||
|
a: float,
|
||||||
|
b: float,
|
||||||
|
c: float,
|
||||||
|
d: float,
|
||||||
|
e: float,
|
||||||
|
nbpts: int = 200,
|
||||||
|
) -> TariffResult:
|
||||||
|
"""
|
||||||
|
Full tariff computation: p0, price curves, and per-household bills.
|
||||||
|
"""
|
||||||
|
p0 = compute_p0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e)
|
||||||
|
|
||||||
|
# Generate curve points
|
||||||
|
tt = np.linspace(0, 1 - 1e-6, nbpts)
|
||||||
|
|
||||||
|
# Tier 1 volumes and prices
|
||||||
|
vv1 = vinf * ((1 - 3 * b) * tt**3 + 3 * b * tt**2)
|
||||||
|
prix_m3_1 = p0 * ((3 * a - 2) * tt**3 + (-6 * a + 3) * tt**2 + 3 * a * tt)
|
||||||
|
|
||||||
|
# Tier 2 volumes and prices
|
||||||
|
vv2 = vinf + (vmax - vinf) * (
|
||||||
|
(3 * (c + d - c * d) - 2) * tt**3
|
||||||
|
+ 3 * (1 - 2 * c - d + c * d) * tt**2
|
||||||
|
+ 3 * c * tt
|
||||||
|
)
|
||||||
|
prix_m3_2 = p0 + (pmax - p0) * ((1 - 3 * e) * tt**3 + 3 * e * tt**2)
|
||||||
|
|
||||||
|
vv = np.concatenate([vv1, vv2])
|
||||||
|
prix_m3 = np.concatenate([prix_m3_1, prix_m3_2])
|
||||||
|
|
||||||
|
# Compute full bills (integral) for each curve point
|
||||||
|
alpha1_arr = np.zeros(len(vv))
|
||||||
|
alpha2_arr = np.zeros(len(vv))
|
||||||
|
beta2_arr = np.zeros(len(vv))
|
||||||
|
for iv, v in enumerate(vv):
|
||||||
|
alpha1_arr[iv], alpha2_arr[iv], beta2_arr[iv] = compute_integrals(
|
||||||
|
v, vinf, vmax, pmax, a, b, c, d, e
|
||||||
|
)
|
||||||
|
|
||||||
|
bills_rp = abop + (alpha1_arr + alpha2_arr) * p0 + beta2_arr
|
||||||
|
bills_rs = abos + (alpha1_arr + alpha2_arr) * p0 + beta2_arr
|
||||||
|
|
||||||
|
# Per-household projected bills
|
||||||
|
household_bills = []
|
||||||
|
for h in households:
|
||||||
|
vol = max(h.volume_m3, 1e-5)
|
||||||
|
abo = abos if h.status == "RS" else abop
|
||||||
|
a1, a2, b2 = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
|
||||||
|
household_bills.append(abo + (a1 + a2) * p0 + b2)
|
||||||
|
|
||||||
|
return TariffResult(
|
||||||
|
p0=p0,
|
||||||
|
curve_volumes=vv.tolist(),
|
||||||
|
curve_prices_m3=prix_m3.tolist(),
|
||||||
|
curve_bills_rp=bills_rp.tolist(),
|
||||||
|
curve_bills_rs=bills_rs.tolist(),
|
||||||
|
household_bills=household_bills,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_impacts(
|
||||||
|
households: list[HouseholdData],
|
||||||
|
recettes: float,
|
||||||
|
abop: float,
|
||||||
|
abos: float,
|
||||||
|
vinf: float,
|
||||||
|
vmax: float,
|
||||||
|
pmax: float,
|
||||||
|
a: float,
|
||||||
|
b: float,
|
||||||
|
c: float,
|
||||||
|
d: float,
|
||||||
|
e: float,
|
||||||
|
reference_volumes: list[float] | None = None,
|
||||||
|
) -> tuple[float, list[ImpactRow]]:
|
||||||
|
"""
|
||||||
|
Compute p0 and price impacts for reference volume levels.
|
||||||
|
|
||||||
|
Returns (p0, list of ImpactRow).
|
||||||
|
"""
|
||||||
|
if reference_volumes is None:
|
||||||
|
reference_volumes = [30, 60, 90, 150, 300]
|
||||||
|
|
||||||
|
p0 = compute_p0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e)
|
||||||
|
|
||||||
|
# Compute average 2018 price per m³ for a rough "old price" baseline
|
||||||
|
total_vol = sum(max(h.volume_m3, 1e-5) for h in households)
|
||||||
|
total_abo_old = sum(abos if h.status == "RS" else abop for h in households)
|
||||||
|
old_p_m3 = (recettes - total_abo_old) / total_vol if total_vol > 0 else 0
|
||||||
|
|
||||||
|
impacts = []
|
||||||
|
for vol in reference_volumes:
|
||||||
|
# Old price (linear model)
|
||||||
|
old_price_rp = abop + old_p_m3 * vol
|
||||||
|
# New price
|
||||||
|
a1, a2, b2 = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
|
||||||
|
new_price_rp = abop + (a1 + a2) * p0 + b2
|
||||||
|
new_price_rs = abos + (a1 + a2) * p0 + b2
|
||||||
|
impacts.append(ImpactRow(
|
||||||
|
volume=vol,
|
||||||
|
old_price=old_price_rp,
|
||||||
|
new_price_rp=new_price_rp,
|
||||||
|
new_price_rs=new_price_rs,
|
||||||
|
))
|
||||||
|
|
||||||
|
return p0, impacts
|
||||||
42
backend/app/main.py
Normal file
42
backend/app/main.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import init_db
|
||||||
|
from app.routers import auth, communes, content, tariff, votes, households
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
description="Outil de démocratie participative pour la tarification de l'eau",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
app.include_router(communes.router, prefix="/api/v1/communes", tags=["communes"])
|
||||||
|
app.include_router(tariff.router, prefix="/api/v1/tariff", tags=["tariff"])
|
||||||
|
app.include_router(votes.router, prefix="/api/v1", tags=["votes"])
|
||||||
|
app.include_router(households.router, prefix="/api/v1", tags=["households"])
|
||||||
|
app.include_router(content.router, prefix="/api/v1/communes", tags=["content"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
9
backend/app/models/__init__.py
Normal file
9
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from app.models.models import (
|
||||||
|
Commune, TariffParams, Household, AdminUser, Vote, CommuneContent,
|
||||||
|
admin_commune_table,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Commune", "TariffParams", "Household", "AdminUser", "Vote",
|
||||||
|
"CommuneContent", "admin_commune_table",
|
||||||
|
]
|
||||||
120
backend/app/models/models.py
Normal file
120
backend/app/models/models.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""SQLAlchemy ORM models."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, Table,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
# Many-to-many: admin users <-> communes
|
||||||
|
admin_commune_table = Table(
|
||||||
|
"admin_commune",
|
||||||
|
Base.metadata,
|
||||||
|
Column("admin_id", Integer, ForeignKey("admin_users.id"), primary_key=True),
|
||||||
|
Column("commune_id", Integer, ForeignKey("communes.id"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Commune(Base):
|
||||||
|
__tablename__ = "communes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
slug = Column(String(200), unique=True, nullable=False, index=True)
|
||||||
|
description = Column(Text, default="")
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
tariff_params = relationship("TariffParams", back_populates="commune", uselist=False)
|
||||||
|
households = relationship("Household", back_populates="commune")
|
||||||
|
votes = relationship("Vote", back_populates="commune")
|
||||||
|
contents = relationship("CommuneContent", back_populates="commune")
|
||||||
|
admins = relationship("AdminUser", secondary=admin_commune_table, back_populates="communes")
|
||||||
|
|
||||||
|
|
||||||
|
class TariffParams(Base):
|
||||||
|
__tablename__ = "tariff_params"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
commune_id = Column(Integer, ForeignKey("communes.id"), unique=True, nullable=False)
|
||||||
|
abop = Column(Float, default=100.0)
|
||||||
|
abos = Column(Float, default=100.0)
|
||||||
|
recettes = Column(Float, default=75000.0)
|
||||||
|
pmax = Column(Float, default=20.0)
|
||||||
|
vmax = Column(Float, default=2100.0)
|
||||||
|
|
||||||
|
commune = relationship("Commune", back_populates="tariff_params")
|
||||||
|
|
||||||
|
|
||||||
|
class Household(Base):
|
||||||
|
__tablename__ = "households"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
commune_id = Column(Integer, ForeignKey("communes.id"), nullable=False)
|
||||||
|
identifier = Column(String(200), nullable=False)
|
||||||
|
status = Column(String(10), nullable=False) # RS, RP, PRO
|
||||||
|
volume_m3 = Column(Float, nullable=False)
|
||||||
|
price_paid_eur = Column(Float, default=0.0)
|
||||||
|
auth_code = Column(String(8), unique=True, nullable=False, index=True)
|
||||||
|
has_voted = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
commune = relationship("Commune", back_populates="households")
|
||||||
|
votes = relationship("Vote", back_populates="household")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("commune_id", "identifier", name="uq_household_commune_identifier"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUser(Base):
|
||||||
|
__tablename__ = "admin_users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
email = Column(String(200), unique=True, nullable=False, index=True)
|
||||||
|
hashed_password = Column(String(200), nullable=False)
|
||||||
|
full_name = Column(String(200), default="")
|
||||||
|
role = Column(String(20), default="commune_admin") # super_admin / commune_admin
|
||||||
|
|
||||||
|
communes = relationship("Commune", secondary=admin_commune_table, back_populates="admins")
|
||||||
|
|
||||||
|
|
||||||
|
class Vote(Base):
|
||||||
|
__tablename__ = "votes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
commune_id = Column(Integer, ForeignKey("communes.id"), nullable=False)
|
||||||
|
household_id = Column(Integer, ForeignKey("households.id"), nullable=False)
|
||||||
|
vinf = Column(Float, nullable=False)
|
||||||
|
a = Column(Float, nullable=False)
|
||||||
|
b = Column(Float, nullable=False)
|
||||||
|
c = Column(Float, nullable=False)
|
||||||
|
d = Column(Float, nullable=False)
|
||||||
|
e = Column(Float, nullable=False)
|
||||||
|
computed_p0 = Column(Float, nullable=True)
|
||||||
|
submitted_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
commune = relationship("Commune", back_populates="votes")
|
||||||
|
household = relationship("Household", back_populates="votes")
|
||||||
|
|
||||||
|
|
||||||
|
class CommuneContent(Base):
|
||||||
|
__tablename__ = "commune_contents"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
commune_id = Column(Integer, ForeignKey("communes.id"), nullable=False)
|
||||||
|
slug = Column(String(200), nullable=False) # page identifier
|
||||||
|
title = Column(String(200), default="")
|
||||||
|
body_markdown = Column(Text, default="")
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
commune = relationship("Commune", back_populates="contents")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("commune_id", "slug", name="uq_content_commune_slug"),
|
||||||
|
)
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
84
backend/app/routers/auth.py
Normal file
84
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import AdminUser, Household, Commune
|
||||||
|
from app.schemas import AdminLogin, CitizenVerify, Token, AdminUserCreate, AdminUserOut
|
||||||
|
from app.services.auth_service import (
|
||||||
|
verify_password, create_admin_token, create_citizen_token,
|
||||||
|
hash_password, require_super_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/login", response_model=Token)
|
||||||
|
async def admin_login(data: AdminLogin, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(AdminUser)
|
||||||
|
.options(selectinload(AdminUser.communes))
|
||||||
|
.where(AdminUser.email == data.email)
|
||||||
|
)
|
||||||
|
admin = result.scalar_one_or_none()
|
||||||
|
if not admin or not verify_password(data.password, admin.hashed_password):
|
||||||
|
raise HTTPException(status_code=401, detail="Identifiants invalides")
|
||||||
|
|
||||||
|
# For commune_admin, include their first commune slug
|
||||||
|
commune_slug = None
|
||||||
|
if admin.communes:
|
||||||
|
commune_slug = admin.communes[0].slug
|
||||||
|
|
||||||
|
return Token(
|
||||||
|
access_token=create_admin_token(admin),
|
||||||
|
role=admin.role,
|
||||||
|
commune_slug=commune_slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/citizen/verify", response_model=Token)
|
||||||
|
async def citizen_verify(data: CitizenVerify, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Household)
|
||||||
|
.join(Commune)
|
||||||
|
.where(Commune.slug == data.commune_slug, Household.auth_code == data.auth_code)
|
||||||
|
)
|
||||||
|
household = result.scalar_one_or_none()
|
||||||
|
if not household:
|
||||||
|
raise HTTPException(status_code=401, detail="Code invalide ou commune introuvable")
|
||||||
|
return Token(
|
||||||
|
access_token=create_citizen_token(household, data.commune_slug),
|
||||||
|
role="citizen",
|
||||||
|
commune_slug=data.commune_slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/create", response_model=AdminUserOut)
|
||||||
|
async def create_admin(
|
||||||
|
data: AdminUserCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current: AdminUser = Depends(require_super_admin),
|
||||||
|
):
|
||||||
|
existing = await db.execute(select(AdminUser).where(AdminUser.email == data.email))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="Email déjà utilisé")
|
||||||
|
|
||||||
|
admin = AdminUser(
|
||||||
|
email=data.email,
|
||||||
|
hashed_password=hash_password(data.password),
|
||||||
|
full_name=data.full_name,
|
||||||
|
role=data.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.commune_slugs:
|
||||||
|
for slug in data.commune_slugs:
|
||||||
|
result = await db.execute(select(Commune).where(Commune.slug == slug))
|
||||||
|
commune = result.scalar_one_or_none()
|
||||||
|
if commune:
|
||||||
|
admin.communes.append(commune)
|
||||||
|
|
||||||
|
db.add(admin)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(admin)
|
||||||
|
return admin
|
||||||
128
backend/app/routers/communes.py
Normal file
128
backend/app/routers/communes.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Commune, TariffParams, Household, Vote, CommuneContent, AdminUser, admin_commune_table
|
||||||
|
from app.schemas import (
|
||||||
|
CommuneCreate, CommuneUpdate, CommuneOut,
|
||||||
|
TariffParamsUpdate, TariffParamsOut,
|
||||||
|
)
|
||||||
|
from app.services.auth_service import get_current_admin, require_super_admin
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[CommuneOut])
|
||||||
|
async def list_communes(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(Commune).where(Commune.is_active == True))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=CommuneOut)
|
||||||
|
async def create_commune(
|
||||||
|
data: CommuneCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(require_super_admin),
|
||||||
|
):
|
||||||
|
existing = await db.execute(select(Commune).where(Commune.slug == data.slug))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="Slug déjà utilisé")
|
||||||
|
|
||||||
|
commune = Commune(name=data.name, slug=data.slug, description=data.description)
|
||||||
|
db.add(commune)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
params = TariffParams(commune_id=commune.id)
|
||||||
|
db.add(params)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(commune)
|
||||||
|
return commune
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}", response_model=CommuneOut)
|
||||||
|
async def get_commune(slug: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
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")
|
||||||
|
return commune
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{slug}", response_model=CommuneOut)
|
||||||
|
async def update_commune(
|
||||||
|
slug: str,
|
||||||
|
data: CommuneUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
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")
|
||||||
|
|
||||||
|
if data.name is not None:
|
||||||
|
commune.name = data.name
|
||||||
|
if data.description is not None:
|
||||||
|
commune.description = data.description
|
||||||
|
if data.is_active is not None:
|
||||||
|
commune.is_active = data.is_active
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(commune)
|
||||||
|
return commune
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{slug}")
|
||||||
|
async def delete_commune(
|
||||||
|
slug: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(require_super_admin),
|
||||||
|
):
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Delete related data in order
|
||||||
|
await db.execute(delete(Vote).where(Vote.commune_id == commune.id))
|
||||||
|
await db.execute(delete(Household).where(Household.commune_id == commune.id))
|
||||||
|
await db.execute(delete(TariffParams).where(TariffParams.commune_id == commune.id))
|
||||||
|
await db.execute(delete(CommuneContent).where(CommuneContent.commune_id == commune.id))
|
||||||
|
await db.execute(delete(admin_commune_table).where(admin_commune_table.c.commune_id == commune.id))
|
||||||
|
await db.delete(commune)
|
||||||
|
await db.commit()
|
||||||
|
return {"detail": f"Commune '{slug}' supprimée"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}/params", response_model=TariffParamsOut)
|
||||||
|
async def get_params(slug: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
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")
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{slug}/params", response_model=TariffParamsOut)
|
||||||
|
async def update_params(
|
||||||
|
slug: str,
|
||||||
|
data: TariffParamsUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
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")
|
||||||
|
|
||||||
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(params, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(params)
|
||||||
|
return params
|
||||||
102
backend/app/routers/content.py
Normal file
102
backend/app/routers/content.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Commune, CommuneContent, AdminUser
|
||||||
|
from app.schemas import ContentUpdate, ContentOut
|
||||||
|
from app.services.auth_service import get_current_admin
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_commune(slug: str, db: AsyncSession) -> Commune:
|
||||||
|
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")
|
||||||
|
return commune
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}/content", response_model=list[ContentOut])
|
||||||
|
async def list_content(slug: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""List all content pages for a commune (public)."""
|
||||||
|
commune = await _get_commune(slug, db)
|
||||||
|
result = await db.execute(
|
||||||
|
select(CommuneContent)
|
||||||
|
.where(CommuneContent.commune_id == commune.id)
|
||||||
|
.order_by(CommuneContent.slug)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}/content/{page_slug}", response_model=ContentOut)
|
||||||
|
async def get_content(slug: str, page_slug: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Get a specific content page (public)."""
|
||||||
|
commune = await _get_commune(slug, db)
|
||||||
|
result = await db.execute(
|
||||||
|
select(CommuneContent)
|
||||||
|
.where(CommuneContent.commune_id == commune.id)
|
||||||
|
.where(CommuneContent.slug == page_slug)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Page introuvable")
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{slug}/content/{page_slug}", response_model=ContentOut)
|
||||||
|
async def upsert_content(
|
||||||
|
slug: str,
|
||||||
|
page_slug: str,
|
||||||
|
data: ContentUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""Create or update a content page (admin only)."""
|
||||||
|
commune = await _get_commune(slug, db)
|
||||||
|
result = await db.execute(
|
||||||
|
select(CommuneContent)
|
||||||
|
.where(CommuneContent.commune_id == commune.id)
|
||||||
|
.where(CommuneContent.slug == page_slug)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if content:
|
||||||
|
content.title = data.title
|
||||||
|
content.body_markdown = data.body_markdown
|
||||||
|
else:
|
||||||
|
content = CommuneContent(
|
||||||
|
commune_id=commune.id,
|
||||||
|
slug=page_slug,
|
||||||
|
title=data.title,
|
||||||
|
body_markdown=data.body_markdown,
|
||||||
|
)
|
||||||
|
db.add(content)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(content)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{slug}/content/{page_slug}")
|
||||||
|
async def delete_content(
|
||||||
|
slug: str,
|
||||||
|
page_slug: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""Delete a content page (admin only)."""
|
||||||
|
commune = await _get_commune(slug, db)
|
||||||
|
result = await db.execute(
|
||||||
|
select(CommuneContent)
|
||||||
|
.where(CommuneContent.commune_id == commune.id)
|
||||||
|
.where(CommuneContent.slug == page_slug)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Page introuvable")
|
||||||
|
|
||||||
|
await db.delete(content)
|
||||||
|
await db.commit()
|
||||||
|
return {"detail": f"Page '{page_slug}' supprimée"}
|
||||||
117
backend/app/routers/households.py
Normal file
117
backend/app/routers/households.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
import io
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Commune, Household, AdminUser
|
||||||
|
from app.schemas import HouseholdOut, HouseholdStats, ImportPreview, ImportResult
|
||||||
|
from app.services.auth_service import get_current_admin
|
||||||
|
from app.services.import_service import parse_import_file, import_households, generate_template_csv
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/communes/{slug}/households/template")
|
||||||
|
async def download_template():
|
||||||
|
content = generate_template_csv()
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(content),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=template_foyers.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/communes/{slug}/households/stats", response_model=HouseholdStats)
|
||||||
|
async def household_stats(slug: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
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 HouseholdStats(
|
||||||
|
total=0, rs_count=0, rp_count=0, pro_count=0,
|
||||||
|
total_volume=0, avg_volume=0, median_volume=0, voted_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
volumes = [h.volume_m3 for h in households]
|
||||||
|
return HouseholdStats(
|
||||||
|
total=len(households),
|
||||||
|
rs_count=sum(1 for h in households if h.status == "RS"),
|
||||||
|
rp_count=sum(1 for h in households if h.status == "RP"),
|
||||||
|
pro_count=sum(1 for h in households if h.status == "PRO"),
|
||||||
|
total_volume=sum(volumes),
|
||||||
|
avg_volume=float(np.mean(volumes)),
|
||||||
|
median_volume=float(np.median(volumes)),
|
||||||
|
voted_count=sum(1 for h in households if h.has_voted),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/communes/{slug}/households/import/preview", response_model=ImportPreview)
|
||||||
|
async def preview_import(
|
||||||
|
slug: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
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")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
df, errors = parse_import_file(content, file.filename)
|
||||||
|
|
||||||
|
if df is None:
|
||||||
|
return ImportPreview(valid_rows=0, errors=errors, sample=[])
|
||||||
|
|
||||||
|
valid_rows = len(df) - len(errors)
|
||||||
|
sample = df.head(5).to_dict(orient="records")
|
||||||
|
return ImportPreview(valid_rows=valid_rows, errors=errors, sample=sample)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/communes/{slug}/households/import", response_model=ImportResult)
|
||||||
|
async def do_import(
|
||||||
|
slug: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
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")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
df, parse_errors = parse_import_file(content, file.filename)
|
||||||
|
|
||||||
|
if df is None or parse_errors:
|
||||||
|
raise HTTPException(status_code=400, detail={"errors": parse_errors})
|
||||||
|
|
||||||
|
created, import_errors = await import_households(db, commune.id, df)
|
||||||
|
return ImportResult(created=created, errors=import_errors)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/communes/{slug}/households", response_model=list[HouseholdOut])
|
||||||
|
async def list_households(
|
||||||
|
slug: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
return hh_result.scalars().all()
|
||||||
96
backend/app/routers/tariff.py
Normal file
96
backend/app/routers/tariff.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Commune, TariffParams, Household
|
||||||
|
from app.schemas import TariffComputeRequest, TariffComputeResponse, ImpactRowOut
|
||||||
|
from app.engine.pricing import HouseholdData, compute_tariff, compute_impacts
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_commune_data(
|
||||||
|
slug: str, db: AsyncSession
|
||||||
|
) -> tuple[list[HouseholdData], TariffParams]:
|
||||||
|
"""Load households and tariff params for a commune."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
params_result = await db.execute(
|
||||||
|
select(TariffParams).where(TariffParams.commune_id == commune.id)
|
||||||
|
)
|
||||||
|
params = params_result.scalar_one_or_none()
|
||||||
|
if not params:
|
||||||
|
raise HTTPException(status_code=404, detail="Paramètres tarifs manquants")
|
||||||
|
|
||||||
|
hh_result = await db.execute(
|
||||||
|
select(Household).where(Household.commune_id == commune.id)
|
||||||
|
)
|
||||||
|
households_db = hh_result.scalars().all()
|
||||||
|
if not households_db:
|
||||||
|
raise HTTPException(status_code=400, detail="Aucun foyer importé pour cette commune")
|
||||||
|
|
||||||
|
households = [
|
||||||
|
HouseholdData(
|
||||||
|
volume_m3=h.volume_m3,
|
||||||
|
status=h.status,
|
||||||
|
price_paid_eur=h.price_paid_eur,
|
||||||
|
)
|
||||||
|
for h in households_db
|
||||||
|
]
|
||||||
|
return households, params
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compute", response_model=TariffComputeResponse)
|
||||||
|
async def compute(data: TariffComputeRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
households, params = await _load_commune_data(data.commune_slug, db)
|
||||||
|
|
||||||
|
result = compute_tariff(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
p0, impacts = compute_impacts(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
return TariffComputeResponse(
|
||||||
|
p0=result.p0,
|
||||||
|
curve_volumes=result.curve_volumes,
|
||||||
|
curve_prices_m3=result.curve_prices_m3,
|
||||||
|
curve_bills_rp=result.curve_bills_rp,
|
||||||
|
curve_bills_rs=result.curve_bills_rs,
|
||||||
|
impacts=[
|
||||||
|
ImpactRowOut(
|
||||||
|
volume=imp.volume,
|
||||||
|
old_price=imp.old_price,
|
||||||
|
new_price_rp=imp.new_price_rp,
|
||||||
|
new_price_rs=imp.new_price_rs,
|
||||||
|
)
|
||||||
|
for imp in impacts
|
||||||
|
],
|
||||||
|
)
|
||||||
287
backend/app/routers/votes.py
Normal file
287
backend/app/routers/votes.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Commune, Household, Vote, TariffParams, AdminUser
|
||||||
|
from app.schemas import VoteCreate, VoteOut, MedianOut, TariffComputeResponse, ImpactRowOut
|
||||||
|
from app.services.auth_service import get_current_citizen, get_current_admin
|
||||||
|
from app.engine.pricing import HouseholdData, compute_p0, compute_tariff, compute_impacts
|
||||||
|
from app.engine.current_model import compute_linear_tariff
|
||||||
|
from app.engine.median import VoteParams, compute_median
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_commune_by_slug(slug: str, db: AsyncSession) -> Commune:
|
||||||
|
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")
|
||||||
|
return commune
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_commune_context(commune_id: int, db: AsyncSession):
|
||||||
|
"""Load tariff params and households for a commune."""
|
||||||
|
params_result = await db.execute(
|
||||||
|
select(TariffParams).where(TariffParams.commune_id == commune_id)
|
||||||
|
)
|
||||||
|
params = params_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
hh_result = await db.execute(
|
||||||
|
select(Household).where(Household.commune_id == commune_id)
|
||||||
|
)
|
||||||
|
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
|
||||||
|
]
|
||||||
|
return params, households
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public endpoint: current median curve for citizens ──
|
||||||
|
|
||||||
|
@router.get("/communes/{slug}/votes/current")
|
||||||
|
async def current_curve(slug: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Public endpoint: returns the current median curve + baseline linear model.
|
||||||
|
No auth required — this is what citizens see when they visit a commune page.
|
||||||
|
|
||||||
|
Always returns the baseline linear model.
|
||||||
|
Returns the median Bézier curve only if votes exist.
|
||||||
|
"""
|
||||||
|
commune = await _get_commune_by_slug(slug, db)
|
||||||
|
params, households = await _load_commune_context(commune.id, db)
|
||||||
|
|
||||||
|
if not params or not households:
|
||||||
|
return {"has_votes": False, "vote_count": 0}
|
||||||
|
|
||||||
|
# Always compute the baseline linear model
|
||||||
|
baseline = compute_linear_tariff(
|
||||||
|
households, recettes=params.recettes,
|
||||||
|
abop=params.abop, abos=params.abos, vmax=params.vmax,
|
||||||
|
)
|
||||||
|
|
||||||
|
baseline_data = {
|
||||||
|
"p0_linear": baseline.p0,
|
||||||
|
"baseline_volumes": baseline.curve_volumes,
|
||||||
|
"baseline_bills_rp": baseline.curve_bills_rp,
|
||||||
|
"baseline_bills_rs": baseline.curve_bills_rs,
|
||||||
|
"baseline_price_m3_rp": baseline.curve_price_m3_rp,
|
||||||
|
"baseline_price_m3_rs": baseline.curve_price_m3_rs,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tariff params for the frontend
|
||||||
|
tariff_params = {
|
||||||
|
"recettes": params.recettes,
|
||||||
|
"abop": params.abop,
|
||||||
|
"abos": params.abos,
|
||||||
|
"pmax": params.pmax,
|
||||||
|
"vmax": params.vmax,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get active votes
|
||||||
|
result = await db.execute(
|
||||||
|
select(Vote).where(Vote.commune_id == commune.id, Vote.is_active == True)
|
||||||
|
)
|
||||||
|
votes = result.scalars().all()
|
||||||
|
|
||||||
|
if not votes:
|
||||||
|
# Return default Bézier curve (a=b=c=d=e=0.5, vinf=vmax/2)
|
||||||
|
default_vinf = params.vmax / 2
|
||||||
|
default_tariff = compute_tariff(
|
||||||
|
households,
|
||||||
|
recettes=params.recettes, abop=params.abop, abos=params.abos,
|
||||||
|
vinf=default_vinf, vmax=params.vmax, pmax=params.pmax,
|
||||||
|
a=0.5, b=0.5, c=0.5, d=0.5, e=0.5,
|
||||||
|
)
|
||||||
|
_, default_impacts = compute_impacts(
|
||||||
|
households,
|
||||||
|
recettes=params.recettes, abop=params.abop, abos=params.abos,
|
||||||
|
vinf=default_vinf, vmax=params.vmax, pmax=params.pmax,
|
||||||
|
a=0.5, b=0.5, c=0.5, d=0.5, e=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has_votes": False,
|
||||||
|
"vote_count": 0,
|
||||||
|
"params": tariff_params,
|
||||||
|
"median": {
|
||||||
|
"vinf": default_vinf, "a": 0.5, "b": 0.5,
|
||||||
|
"c": 0.5, "d": 0.5, "e": 0.5,
|
||||||
|
},
|
||||||
|
"p0": default_tariff.p0,
|
||||||
|
"curve_volumes": default_tariff.curve_volumes,
|
||||||
|
"curve_prices_m3": default_tariff.curve_prices_m3,
|
||||||
|
"curve_bills_rp": default_tariff.curve_bills_rp,
|
||||||
|
"curve_bills_rs": default_tariff.curve_bills_rs,
|
||||||
|
"impacts": [
|
||||||
|
{"volume": imp.volume, "old_price": imp.old_price,
|
||||||
|
"new_price_rp": imp.new_price_rp, "new_price_rs": imp.new_price_rs}
|
||||||
|
for imp in default_impacts
|
||||||
|
],
|
||||||
|
**baseline_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compute median
|
||||||
|
vote_params = [
|
||||||
|
VoteParams(vinf=v.vinf, a=v.a, b=v.b, c=v.c, d=v.d, e=v.e)
|
||||||
|
for v in votes
|
||||||
|
]
|
||||||
|
median = compute_median(vote_params)
|
||||||
|
|
||||||
|
# Compute full tariff for the median
|
||||||
|
tariff = compute_tariff(
|
||||||
|
households,
|
||||||
|
recettes=params.recettes, abop=params.abop, abos=params.abos,
|
||||||
|
vinf=median.vinf, vmax=params.vmax, pmax=params.pmax,
|
||||||
|
a=median.a, b=median.b, c=median.c, d=median.d, e=median.e,
|
||||||
|
)
|
||||||
|
|
||||||
|
_, impacts = compute_impacts(
|
||||||
|
households,
|
||||||
|
recettes=params.recettes, abop=params.abop, abos=params.abos,
|
||||||
|
vinf=median.vinf, vmax=params.vmax, pmax=params.pmax,
|
||||||
|
a=median.a, b=median.b, c=median.c, d=median.d, e=median.e,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has_votes": True,
|
||||||
|
"vote_count": len(votes),
|
||||||
|
"params": tariff_params,
|
||||||
|
"median": {
|
||||||
|
"vinf": median.vinf,
|
||||||
|
"a": median.a,
|
||||||
|
"b": median.b,
|
||||||
|
"c": median.c,
|
||||||
|
"d": median.d,
|
||||||
|
"e": median.e,
|
||||||
|
},
|
||||||
|
"p0": tariff.p0,
|
||||||
|
"curve_volumes": tariff.curve_volumes,
|
||||||
|
"curve_prices_m3": tariff.curve_prices_m3,
|
||||||
|
"curve_bills_rp": tariff.curve_bills_rp,
|
||||||
|
"curve_bills_rs": tariff.curve_bills_rs,
|
||||||
|
"impacts": [
|
||||||
|
{"volume": imp.volume, "old_price": imp.old_price,
|
||||||
|
"new_price_rp": imp.new_price_rp, "new_price_rs": imp.new_price_rs}
|
||||||
|
for imp in impacts
|
||||||
|
],
|
||||||
|
**baseline_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Citizen: submit vote ──
|
||||||
|
|
||||||
|
@router.post("/communes/{slug}/votes", response_model=VoteOut)
|
||||||
|
async def submit_vote(
|
||||||
|
slug: str,
|
||||||
|
data: VoteCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
household: Household = Depends(get_current_citizen),
|
||||||
|
):
|
||||||
|
commune = await _get_commune_by_slug(slug, db)
|
||||||
|
|
||||||
|
if household.commune_id != commune.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Accès interdit à cette commune")
|
||||||
|
|
||||||
|
# Deactivate previous votes
|
||||||
|
await db.execute(
|
||||||
|
update(Vote)
|
||||||
|
.where(Vote.household_id == household.id, Vote.is_active == True)
|
||||||
|
.values(is_active=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
params, households = await _load_commune_context(commune.id, db)
|
||||||
|
|
||||||
|
computed_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,
|
||||||
|
) if params else None
|
||||||
|
|
||||||
|
vote = Vote(
|
||||||
|
commune_id=commune.id,
|
||||||
|
household_id=household.id,
|
||||||
|
vinf=data.vinf, a=data.a, b=data.b, c=data.c, d=data.d, e=data.e,
|
||||||
|
computed_p0=computed_p0,
|
||||||
|
)
|
||||||
|
db.add(vote)
|
||||||
|
household.has_voted = True
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(vote)
|
||||||
|
return vote
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin: list votes ──
|
||||||
|
|
||||||
|
@router.get("/communes/{slug}/votes", response_model=list[VoteOut])
|
||||||
|
async def list_votes(
|
||||||
|
slug: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
commune = await _get_commune_by_slug(slug, db)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Vote).where(Vote.commune_id == commune.id, Vote.is_active == True)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin: median ──
|
||||||
|
|
||||||
|
@router.get("/communes/{slug}/votes/median", response_model=MedianOut)
|
||||||
|
async def vote_median(
|
||||||
|
slug: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
commune = await _get_commune_by_slug(slug, db)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Vote).where(Vote.commune_id == commune.id, Vote.is_active == True)
|
||||||
|
)
|
||||||
|
votes = result.scalars().all()
|
||||||
|
if not votes:
|
||||||
|
raise HTTPException(status_code=404, detail="Aucun vote actif")
|
||||||
|
|
||||||
|
vote_params = [
|
||||||
|
VoteParams(vinf=v.vinf, a=v.a, b=v.b, c=v.c, d=v.d, e=v.e)
|
||||||
|
for v in votes
|
||||||
|
]
|
||||||
|
median = compute_median(vote_params)
|
||||||
|
|
||||||
|
params, households = await _load_commune_context(commune.id, db)
|
||||||
|
|
||||||
|
computed_p0 = compute_p0(
|
||||||
|
households,
|
||||||
|
recettes=params.recettes, abop=params.abop, abos=params.abos,
|
||||||
|
vinf=median.vinf, vmax=params.vmax, pmax=params.pmax,
|
||||||
|
a=median.a, b=median.b, c=median.c, d=median.d, e=median.e,
|
||||||
|
) if params else 0
|
||||||
|
|
||||||
|
return MedianOut(
|
||||||
|
vinf=median.vinf, a=median.a, b=median.b, c=median.c, d=median.d, e=median.e,
|
||||||
|
computed_p0=computed_p0, vote_count=len(votes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin: overlay ──
|
||||||
|
|
||||||
|
@router.get("/communes/{slug}/votes/overlay")
|
||||||
|
async def vote_overlay(
|
||||||
|
slug: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
commune = await _get_commune_by_slug(slug, db)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Vote).where(Vote.commune_id == commune.id, Vote.is_active == True)
|
||||||
|
)
|
||||||
|
votes = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{"id": v.id, "vinf": v.vinf, "a": v.a, "b": v.b,
|
||||||
|
"c": v.c, "d": v.d, "e": v.e, "computed_p0": v.computed_p0}
|
||||||
|
for v in votes
|
||||||
|
]
|
||||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from app.schemas.schemas import * # noqa: F401, F403
|
||||||
205
backend/app/schemas/schemas.py
Normal file
205
backend/app/schemas/schemas.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""Pydantic schemas for API request/response validation."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth ──
|
||||||
|
|
||||||
|
class AdminLogin(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class CitizenVerify(BaseModel):
|
||||||
|
commune_slug: str
|
||||||
|
auth_code: str
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
role: str
|
||||||
|
commune_slug: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Commune ──
|
||||||
|
|
||||||
|
class CommuneCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CommuneUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommuneOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
description: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── TariffParams ──
|
||||||
|
|
||||||
|
class TariffParamsUpdate(BaseModel):
|
||||||
|
abop: float | None = None
|
||||||
|
abos: float | None = None
|
||||||
|
recettes: float | None = None
|
||||||
|
pmax: float | None = None
|
||||||
|
vmax: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TariffParamsOut(BaseModel):
|
||||||
|
abop: float
|
||||||
|
abos: float
|
||||||
|
recettes: float
|
||||||
|
pmax: float
|
||||||
|
vmax: float
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Household ──
|
||||||
|
|
||||||
|
class HouseholdOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
identifier: str
|
||||||
|
status: str
|
||||||
|
volume_m3: float
|
||||||
|
price_paid_eur: float
|
||||||
|
auth_code: str
|
||||||
|
has_voted: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class HouseholdStats(BaseModel):
|
||||||
|
total: int
|
||||||
|
rs_count: int
|
||||||
|
rp_count: int
|
||||||
|
pro_count: int
|
||||||
|
total_volume: float
|
||||||
|
avg_volume: float
|
||||||
|
median_volume: float
|
||||||
|
voted_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ImportPreview(BaseModel):
|
||||||
|
valid_rows: int
|
||||||
|
errors: list[str]
|
||||||
|
sample: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class ImportResult(BaseModel):
|
||||||
|
created: int
|
||||||
|
errors: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tariff Compute ──
|
||||||
|
|
||||||
|
class TariffComputeRequest(BaseModel):
|
||||||
|
commune_slug: str
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class ImpactRowOut(BaseModel):
|
||||||
|
volume: float
|
||||||
|
old_price: float
|
||||||
|
new_price_rp: float
|
||||||
|
new_price_rs: float
|
||||||
|
|
||||||
|
|
||||||
|
class TariffComputeResponse(BaseModel):
|
||||||
|
p0: float
|
||||||
|
curve_volumes: list[float]
|
||||||
|
curve_prices_m3: list[float]
|
||||||
|
curve_bills_rp: list[float]
|
||||||
|
curve_bills_rs: list[float]
|
||||||
|
impacts: list[ImpactRowOut]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Vote ──
|
||||||
|
|
||||||
|
class VoteCreate(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)
|
||||||
|
|
||||||
|
|
||||||
|
class VoteOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
household_id: int
|
||||||
|
vinf: float
|
||||||
|
a: float
|
||||||
|
b: float
|
||||||
|
c: float
|
||||||
|
d: float
|
||||||
|
e: float
|
||||||
|
computed_p0: float | None
|
||||||
|
submitted_at: datetime
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class MedianOut(BaseModel):
|
||||||
|
vinf: float
|
||||||
|
a: float
|
||||||
|
b: float
|
||||||
|
c: float
|
||||||
|
d: float
|
||||||
|
e: float
|
||||||
|
computed_p0: float
|
||||||
|
vote_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin User ──
|
||||||
|
|
||||||
|
class AdminUserCreate(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
full_name: str = ""
|
||||||
|
role: str = "commune_admin"
|
||||||
|
commune_slugs: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
full_name: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Content ──
|
||||||
|
|
||||||
|
class ContentUpdate(BaseModel):
|
||||||
|
title: str
|
||||||
|
body_markdown: str
|
||||||
|
|
||||||
|
|
||||||
|
class ContentOut(BaseModel):
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
body_markdown: str
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
89
backend/app/services/auth_service.py
Normal file
89
backend/app/services/auth_service.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Authentication service: JWT creation/validation, password hashing."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import AdminUser, Household
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def create_token(data: dict, expires_hours: int) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
to_encode["exp"] = datetime.utcnow() + timedelta(hours=expires_hours)
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def create_admin_token(admin: AdminUser) -> str:
|
||||||
|
return create_token(
|
||||||
|
{"sub": str(admin.id), "role": admin.role, "type": "admin"},
|
||||||
|
settings.ADMIN_TOKEN_EXPIRE_HOURS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_citizen_token(household: Household, commune_slug: str) -> str:
|
||||||
|
return create_token(
|
||||||
|
{
|
||||||
|
"sub": str(household.id),
|
||||||
|
"commune_id": household.commune_id,
|
||||||
|
"commune_slug": commune_slug,
|
||||||
|
"type": "citizen",
|
||||||
|
},
|
||||||
|
settings.CITIZEN_TOKEN_EXPIRE_HOURS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_admin(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> AdminUser:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
if payload.get("type") != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
admin = await db.get(AdminUser, int(payload["sub"]))
|
||||||
|
if not admin:
|
||||||
|
raise HTTPException(status_code=401, detail="Admin not found")
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_citizen(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> Household:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
if payload.get("type") != "citizen":
|
||||||
|
raise HTTPException(status_code=403, detail="Citizen access required")
|
||||||
|
household = await db.get(Household, int(payload["sub"]))
|
||||||
|
if not household:
|
||||||
|
raise HTTPException(status_code=401, detail="Household not found")
|
||||||
|
return household
|
||||||
|
|
||||||
|
|
||||||
|
def require_super_admin(admin: AdminUser = Depends(get_current_admin)) -> AdminUser:
|
||||||
|
if admin.role != "super_admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Super admin access required")
|
||||||
|
return admin
|
||||||
143
backend/app/services/import_service.py
Normal file
143
backend/app/services/import_service.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Service for importing household data from CSV/XLSX files."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models import Household
|
||||||
|
|
||||||
|
# Characters without ambiguous ones (O/0/I/1/l)
|
||||||
|
SAFE_CHARS = string.ascii_uppercase.replace("O", "").replace("I", "") + string.digits.replace("0", "").replace("1", "")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_auth_code(length: int = 8) -> str:
|
||||||
|
return "".join(secrets.choice(SAFE_CHARS) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
VALID_STATUSES = {"RS", "RP", "PRO"}
|
||||||
|
REQUIRED_COLUMNS = {"identifier", "status", "volume_m3", "price_eur"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_import_file(file_bytes: bytes, filename: str) -> tuple[pd.DataFrame | None, list[str]]:
|
||||||
|
"""
|
||||||
|
Parse a CSV or XLSX file and validate its contents.
|
||||||
|
|
||||||
|
Returns (dataframe, errors). If errors is non-empty, dataframe may be None.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if filename.endswith(".csv"):
|
||||||
|
df = pd.read_csv(io.BytesIO(file_bytes))
|
||||||
|
elif filename.endswith((".xlsx", ".xls")):
|
||||||
|
df = pd.read_excel(io.BytesIO(file_bytes))
|
||||||
|
else:
|
||||||
|
return None, ["Format non supporté. Utilisez CSV ou XLSX."]
|
||||||
|
except Exception as e:
|
||||||
|
return None, [f"Erreur de lecture du fichier: {e}"]
|
||||||
|
|
||||||
|
# Normalize column names
|
||||||
|
df.columns = [c.strip().lower().replace(" ", "_") for c in df.columns]
|
||||||
|
|
||||||
|
missing = REQUIRED_COLUMNS - set(df.columns)
|
||||||
|
if missing:
|
||||||
|
return None, [f"Colonnes manquantes: {', '.join(missing)}"]
|
||||||
|
|
||||||
|
# Validate rows
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
line = idx + 2 # Excel line number (1-indexed + header)
|
||||||
|
status = str(row["status"]).strip().upper()
|
||||||
|
if status not in VALID_STATUSES:
|
||||||
|
errors.append(f"Ligne {line}: statut '{row['status']}' invalide (attendu: RS, RP, PRO)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
vol = float(row["volume_m3"])
|
||||||
|
if vol < 0:
|
||||||
|
errors.append(f"Ligne {line}: volume négatif ({vol})")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
errors.append(f"Ligne {line}: volume invalide '{row['volume_m3']}'")
|
||||||
|
|
||||||
|
price = row.get("price_eur")
|
||||||
|
if pd.notna(price):
|
||||||
|
try:
|
||||||
|
p = float(price)
|
||||||
|
if p < 0:
|
||||||
|
errors.append(f"Ligne {line}: prix négatif ({p})")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
errors.append(f"Ligne {line}: prix invalide '{price}'")
|
||||||
|
|
||||||
|
# Normalize
|
||||||
|
df["status"] = df["status"].str.strip().str.upper()
|
||||||
|
df["identifier"] = df["identifier"].astype(str).str.strip()
|
||||||
|
|
||||||
|
return df, errors
|
||||||
|
|
||||||
|
|
||||||
|
async def import_households(
|
||||||
|
db: AsyncSession,
|
||||||
|
commune_id: int,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
) -> tuple[int, list[str]]:
|
||||||
|
"""
|
||||||
|
Import validated households into the database.
|
||||||
|
|
||||||
|
Returns (created_count, errors).
|
||||||
|
"""
|
||||||
|
created = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Get existing auth codes to avoid collisions
|
||||||
|
existing_codes = set()
|
||||||
|
result = await db.execute(select(Household.auth_code))
|
||||||
|
for row in result.scalars():
|
||||||
|
existing_codes.add(row)
|
||||||
|
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
identifier = str(row["identifier"]).strip()
|
||||||
|
status = str(row["status"]).strip().upper()
|
||||||
|
volume = float(row["volume_m3"])
|
||||||
|
price = float(row["price_eur"]) if pd.notna(row.get("price_eur")) else 0.0
|
||||||
|
|
||||||
|
# Check for duplicate
|
||||||
|
existing = await db.execute(
|
||||||
|
select(Household).where(
|
||||||
|
Household.commune_id == commune_id,
|
||||||
|
Household.identifier == identifier,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
errors.append(f"Foyer '{identifier}' existe déjà, ignoré.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Generate unique auth code
|
||||||
|
code = generate_auth_code()
|
||||||
|
while code in existing_codes:
|
||||||
|
code = generate_auth_code()
|
||||||
|
existing_codes.add(code)
|
||||||
|
|
||||||
|
household = Household(
|
||||||
|
commune_id=commune_id,
|
||||||
|
identifier=identifier,
|
||||||
|
status=status,
|
||||||
|
volume_m3=volume,
|
||||||
|
price_paid_eur=price,
|
||||||
|
auth_code=code,
|
||||||
|
)
|
||||||
|
db.add(household)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return created, errors
|
||||||
|
|
||||||
|
|
||||||
|
def generate_template_csv() -> bytes:
|
||||||
|
"""Generate a template CSV file for household import."""
|
||||||
|
content = "identifier,status,volume_m3,price_eur\n"
|
||||||
|
content += "DUPONT Jean,RS,85.5,189.50\n"
|
||||||
|
content += "MARTIN Pierre,RP,120.0,245.00\n"
|
||||||
|
content += "SARL Boulangerie,PRO,350.0,\n"
|
||||||
|
return content.encode("utf-8")
|
||||||
18
backend/requirements.txt
Normal file
18
backend/requirements.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
sqlalchemy==2.0.36
|
||||||
|
alembic==1.14.0
|
||||||
|
pydantic==2.10.3
|
||||||
|
pydantic-settings==2.7.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.1.3
|
||||||
|
python-multipart==0.0.18
|
||||||
|
numpy==1.26.4
|
||||||
|
openpyxl==3.1.5
|
||||||
|
xlrd==2.0.1
|
||||||
|
pandas==2.2.3
|
||||||
|
aiosqlite==0.20.0
|
||||||
|
pytest==8.3.4
|
||||||
|
pytest-asyncio==0.24.0
|
||||||
|
httpx==0.28.1
|
||||||
103
backend/seed.py
Normal file
103
backend/seed.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Seed the database with Saoû data from Eau2018.xls."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
import xlrd
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import engine, async_session, init_db
|
||||||
|
from app.models import Commune, TariffParams, Household, AdminUser
|
||||||
|
from app.services.auth_service import hash_password
|
||||||
|
from app.services.import_service import generate_auth_code
|
||||||
|
|
||||||
|
XLS_PATH = os.path.join(os.path.dirname(__file__), "..", "Eau2018.xls")
|
||||||
|
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
await init_db()
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
# Check if already seeded
|
||||||
|
result = await db.execute(select(Commune).where(Commune.slug == "saou"))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
print("Saoû already seeded.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create commune
|
||||||
|
commune = Commune(
|
||||||
|
name="Saoû",
|
||||||
|
slug="saou",
|
||||||
|
description="Commune de Saoû - Tarification progressive de l'eau",
|
||||||
|
)
|
||||||
|
db.add(commune)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Create tariff params
|
||||||
|
params = TariffParams(
|
||||||
|
commune_id=commune.id,
|
||||||
|
abop=100,
|
||||||
|
abos=100,
|
||||||
|
recettes=75000,
|
||||||
|
pmax=20,
|
||||||
|
vmax=2100,
|
||||||
|
)
|
||||||
|
db.add(params)
|
||||||
|
|
||||||
|
# Create super admin (manages all communes)
|
||||||
|
super_admin = AdminUser(
|
||||||
|
email="superadmin@sejeteralo.fr",
|
||||||
|
hashed_password=hash_password("superadmin"),
|
||||||
|
full_name="Super Admin",
|
||||||
|
role="super_admin",
|
||||||
|
)
|
||||||
|
db.add(super_admin)
|
||||||
|
|
||||||
|
# Create commune admin for Saoû (manages only this commune)
|
||||||
|
commune_admin = AdminUser(
|
||||||
|
email="saou@sejeteralo.fr",
|
||||||
|
hashed_password=hash_password("saou2024"),
|
||||||
|
full_name="Admin Saoû",
|
||||||
|
role="commune_admin",
|
||||||
|
)
|
||||||
|
commune_admin.communes.append(commune)
|
||||||
|
db.add(commune_admin)
|
||||||
|
|
||||||
|
# Import households from Eau2018.xls
|
||||||
|
book = xlrd.open_workbook(XLS_PATH)
|
||||||
|
sheet = book.sheet_by_name("CALCULS")
|
||||||
|
nb_hab = 363
|
||||||
|
|
||||||
|
existing_codes = set()
|
||||||
|
for r in range(1, nb_hab + 1):
|
||||||
|
name = sheet.cell_value(r, 0)
|
||||||
|
status = sheet.cell_value(r, 3)
|
||||||
|
volume = sheet.cell_value(r, 4)
|
||||||
|
price = sheet.cell_value(r, 33)
|
||||||
|
|
||||||
|
code = generate_auth_code()
|
||||||
|
while code in existing_codes:
|
||||||
|
code = generate_auth_code()
|
||||||
|
existing_codes.add(code)
|
||||||
|
|
||||||
|
household = Household(
|
||||||
|
commune_id=commune.id,
|
||||||
|
identifier=str(name).strip(),
|
||||||
|
status=str(status).strip().upper(),
|
||||||
|
volume_m3=float(volume),
|
||||||
|
price_paid_eur=float(price) if price else 0.0,
|
||||||
|
auth_code=code,
|
||||||
|
)
|
||||||
|
db.add(household)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
print(f"Seeded: commune 'saou', {nb_hab} households")
|
||||||
|
print(f" Super admin: superadmin@sejeteralo.fr / superadmin")
|
||||||
|
print(f" Commune admin Saoû: saou@sejeteralo.fr / saou2024")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed())
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
280
backend/tests/test_engine.py
Normal file
280
backend/tests/test_engine.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""
|
||||||
|
Tests for the extracted math engine.
|
||||||
|
|
||||||
|
Validates that the engine produces identical results to the original eau.py
|
||||||
|
using the Saoû data (Eau2018.xls).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import xlrd
|
||||||
|
|
||||||
|
# Add backend to path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from app.engine.integrals import compute_integrals
|
||||||
|
from app.engine.pricing import HouseholdData, compute_p0, compute_tariff
|
||||||
|
from app.engine.current_model import compute_linear_tariff
|
||||||
|
from app.engine.median import VoteParams, compute_median
|
||||||
|
|
||||||
|
|
||||||
|
# Path to the Excel file
|
||||||
|
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "data")
|
||||||
|
XLS_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "Eau2018.xls")
|
||||||
|
|
||||||
|
|
||||||
|
def load_saou_households() -> list[HouseholdData]:
|
||||||
|
"""Load household data from Eau2018.xls exactly as eau.py does."""
|
||||||
|
book = xlrd.open_workbook(XLS_PATH)
|
||||||
|
sheet = book.sheet_by_name("CALCULS")
|
||||||
|
nb_hab = 363
|
||||||
|
households = []
|
||||||
|
for r in range(1, nb_hab + 1):
|
||||||
|
vol = sheet.cell_value(r, 4)
|
||||||
|
status = sheet.cell_value(r, 3)
|
||||||
|
prix = sheet.cell_value(r, 33)
|
||||||
|
households.append(HouseholdData(
|
||||||
|
volume_m3=vol,
|
||||||
|
status=status,
|
||||||
|
price_paid_eur=prix,
|
||||||
|
))
|
||||||
|
return households
|
||||||
|
|
||||||
|
|
||||||
|
# Reference original eau.py computeIntegrals for comparison
|
||||||
|
def original_compute_integrals(vv, vinf, vmax, pmax, a, b, c, d, e):
|
||||||
|
"""Direct port of eau.py computeIntegrals for validation."""
|
||||||
|
if vv <= vinf:
|
||||||
|
if vv == 0:
|
||||||
|
T = 0.0
|
||||||
|
elif vv == vinf:
|
||||||
|
T = 1.0
|
||||||
|
else:
|
||||||
|
p = [1 - 3 * b, 3 * b, 0, -vv / vinf]
|
||||||
|
roots = np.roots(p)
|
||||||
|
roots = np.unique(roots)
|
||||||
|
roots2 = np.real(roots[np.isreal(roots)])
|
||||||
|
mask = (roots2 <= 1.0) & (roots2 >= 0.0)
|
||||||
|
T = float(roots2[mask])
|
||||||
|
alpha1 = 3 * vinf * (
|
||||||
|
T**6 / 6 * (-9 * a * b + 3 * a + 6 * b - 2)
|
||||||
|
+ T**5 / 5 * (24 * a * b - 6 * a - 13 * b + 3)
|
||||||
|
+ 3 * T**4 / 4 * (-7 * a * b + a + 2 * b)
|
||||||
|
+ T**3 / 3 * 6 * a * b
|
||||||
|
)
|
||||||
|
return alpha1, 0, 0
|
||||||
|
else:
|
||||||
|
alpha1 = 3 * vinf * (
|
||||||
|
1 / 6 * (-9 * a * b + 3 * a + 6 * b - 2)
|
||||||
|
+ 1 / 5 * (24 * a * b - 6 * a - 13 * b + 3)
|
||||||
|
+ 3 / 4 * (-7 * a * b + a + 2 * b)
|
||||||
|
+ 1 / 3 * 6 * a * b
|
||||||
|
)
|
||||||
|
wmax = vmax - vinf
|
||||||
|
if vv == vinf:
|
||||||
|
T = 0.0
|
||||||
|
elif vv == vmax:
|
||||||
|
T = 1.0
|
||||||
|
else:
|
||||||
|
p = [3 * (c + d - c * d) - 2, 3 * (1 - 2 * c - d + c * d), 3 * c, -(vv - vinf) / wmax]
|
||||||
|
roots = np.roots(p)
|
||||||
|
roots = np.unique(roots)
|
||||||
|
roots2 = np.real(roots[np.isreal(roots)])
|
||||||
|
mask = (roots2 <= 1.0) & (roots2 >= 0.0)
|
||||||
|
T = float(np.real(roots2[mask]))
|
||||||
|
uu = (
|
||||||
|
(-3 * c * d + 9 * e * c * d + 3 * c - 9 * e * c + 3 * d - 9 * e * d + 6 * e - 2) * T**6 / 6
|
||||||
|
+ (2 * c * d - 15 * e * c * d - 4 * c + 21 * e * c - 2 * d + 15 * e * d - 12 * e + 2) * T**5 / 5
|
||||||
|
+ (6 * e * c * d + c - 15 * e * c - 6 * e * d + 6 * e) * T**4 / 4
|
||||||
|
+ (3 * e * c) * T**3 / 3
|
||||||
|
)
|
||||||
|
alpha2 = vv - vinf - 3 * uu * wmax
|
||||||
|
beta2 = 3 * pmax * wmax * uu
|
||||||
|
return alpha1, alpha2, beta2
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegrals:
|
||||||
|
"""Test the integral computation against the original."""
|
||||||
|
|
||||||
|
def test_tier1_zero_volume(self):
|
||||||
|
a1, a2, b2 = compute_integrals(0, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
|
||||||
|
assert a1 == 0.0
|
||||||
|
assert a2 == 0.0
|
||||||
|
assert b2 == 0.0
|
||||||
|
|
||||||
|
def test_tier1_at_vinf(self):
|
||||||
|
a1, a2, b2 = compute_integrals(1050, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
|
||||||
|
oa1, oa2, ob2 = original_compute_integrals(1050, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
|
||||||
|
assert abs(a1 - oa1) < 1e-10
|
||||||
|
assert a2 == 0.0
|
||||||
|
assert b2 == 0.0
|
||||||
|
|
||||||
|
def test_tier2_at_vmax(self):
|
||||||
|
a1, a2, b2 = compute_integrals(2100, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
|
||||||
|
oa1, oa2, ob2 = original_compute_integrals(2100, 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5)
|
||||||
|
assert abs(a1 - oa1) < 1e-10
|
||||||
|
assert abs(a2 - oa2) < 1e-6
|
||||||
|
assert abs(b2 - ob2) < 1e-6
|
||||||
|
|
||||||
|
def test_various_volumes_match_original(self):
|
||||||
|
"""Test multiple volumes with various parameter sets."""
|
||||||
|
params_sets = [
|
||||||
|
(0.5, 0.5, 0.5, 0.5, 0.5),
|
||||||
|
(0.25, 0.75, 0.3, 0.6, 0.8),
|
||||||
|
(0.1, 0.1, 0.9, 0.9, 0.1),
|
||||||
|
(0.9, 0.9, 0.1, 0.1, 0.9),
|
||||||
|
]
|
||||||
|
volumes = [0, 10, 50, 100, 300, 500, 1000, 1050, 1051, 1500, 2000, 2100]
|
||||||
|
|
||||||
|
for a, b, c, d, e in params_sets:
|
||||||
|
for vol in volumes:
|
||||||
|
vinf, vmax, pmax = 1050, 2100, 20
|
||||||
|
result = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
|
||||||
|
expected = original_compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e)
|
||||||
|
for i in range(3):
|
||||||
|
assert abs(result[i] - expected[i]) < 1e-6, (
|
||||||
|
f"Mismatch at vol={vol}, params=({a},{b},{c},{d},{e}), "
|
||||||
|
f"component={i}: got {result[i]}, expected {expected[i]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPricing:
|
||||||
|
"""Test the pricing computation with Saoû data."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def saou_households(self):
|
||||||
|
return load_saou_households()
|
||||||
|
|
||||||
|
def test_saou_data_loaded(self, saou_households):
|
||||||
|
assert len(saou_households) == 363
|
||||||
|
|
||||||
|
def test_p0_default_params(self, saou_households):
|
||||||
|
"""Test p0 with default slider values from eau.py mainFunction."""
|
||||||
|
# Default values from eau.py lines 54-62
|
||||||
|
p0 = compute_p0(
|
||||||
|
saou_households,
|
||||||
|
recettes=75000, # recettesArray[25]
|
||||||
|
abop=100, # abopArray[100]
|
||||||
|
abos=100, # abosArray[100]
|
||||||
|
vinf=1050, # vinfArray[vmax/2]
|
||||||
|
vmax=2100,
|
||||||
|
pmax=20,
|
||||||
|
a=0.5, # aArray[25]
|
||||||
|
b=0.5, # bArray[25]
|
||||||
|
c=0.5, # cArray[25]
|
||||||
|
d=0.5, # dArray[25]
|
||||||
|
e=0.5, # eArray[25]
|
||||||
|
)
|
||||||
|
# Compute the same p0 using original algorithm
|
||||||
|
volumes = np.array([max(h.volume_m3, 1e-5) for h in saou_households])
|
||||||
|
statuses = np.array([h.status for h in saou_households])
|
||||||
|
|
||||||
|
abo = 100 * np.ones(363)
|
||||||
|
abo[statuses == "RS"] = 100
|
||||||
|
|
||||||
|
alpha1_arr = np.zeros(363)
|
||||||
|
alpha2_arr = np.zeros(363)
|
||||||
|
beta2_arr = np.zeros(363)
|
||||||
|
for ih in range(363):
|
||||||
|
alpha1_arr[ih], alpha2_arr[ih], beta2_arr[ih] = original_compute_integrals(
|
||||||
|
volumes[ih], 1050, 2100, 20, 0.5, 0.5, 0.5, 0.5, 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_p0 = (75000 - np.sum(beta2_arr + abo)) / np.sum(alpha1_arr + alpha2_arr)
|
||||||
|
|
||||||
|
assert abs(p0 - expected_p0) < 1e-6, f"p0={p0}, expected={expected_p0}"
|
||||||
|
assert p0 > 0, "p0 should be positive"
|
||||||
|
|
||||||
|
def test_p0_various_params(self, saou_households):
|
||||||
|
"""Test p0 with various parameter sets."""
|
||||||
|
param_sets = [
|
||||||
|
(75000, 100, 100, 1050, 0.5, 0.5, 0.5, 0.5, 0.5),
|
||||||
|
(60000, 80, 80, 800, 0.3, 0.7, 0.4, 0.6, 0.2),
|
||||||
|
(90000, 120, 90, 1200, 0.8, 0.2, 0.6, 0.3, 0.7),
|
||||||
|
]
|
||||||
|
for recettes, abop, abos, vinf, a, b, c, d, e in param_sets:
|
||||||
|
p0 = compute_p0(
|
||||||
|
saou_households, recettes, abop, abos, vinf, 2100, 20, a, b, c, d, e
|
||||||
|
)
|
||||||
|
# Verify: total bills should equal recettes
|
||||||
|
total = 0
|
||||||
|
for h in saou_households:
|
||||||
|
vol = max(h.volume_m3, 1e-5)
|
||||||
|
abo_val = abos if h.status == "RS" else abop
|
||||||
|
a1, a2, b2 = compute_integrals(vol, vinf, 2100, 20, a, b, c, d, e)
|
||||||
|
total += abo_val + (a1 + a2) * p0 + b2
|
||||||
|
|
||||||
|
assert abs(total - recettes) < 0.01, (
|
||||||
|
f"Revenue mismatch: got {total:.2f}, expected {recettes}. "
|
||||||
|
f"Params: recettes={recettes}, abop={abop}, abos={abos}, vinf={vinf}, "
|
||||||
|
f"a={a}, b={b}, c={c}, d={d}, e={e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_full_tariff_computation(self, saou_households):
|
||||||
|
result = compute_tariff(
|
||||||
|
saou_households,
|
||||||
|
recettes=75000,
|
||||||
|
abop=100,
|
||||||
|
abos=100,
|
||||||
|
vinf=1050,
|
||||||
|
vmax=2100,
|
||||||
|
pmax=20,
|
||||||
|
a=0.5,
|
||||||
|
b=0.5,
|
||||||
|
c=0.5,
|
||||||
|
d=0.5,
|
||||||
|
e=0.5,
|
||||||
|
)
|
||||||
|
assert result.p0 > 0
|
||||||
|
assert len(result.curve_volumes) == 400 # 200 * 2 tiers
|
||||||
|
assert len(result.household_bills) == 363
|
||||||
|
|
||||||
|
|
||||||
|
class TestLinearModel:
|
||||||
|
"""Test the linear (current) pricing model."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def saou_households(self):
|
||||||
|
return load_saou_households()
|
||||||
|
|
||||||
|
def test_linear_p0(self, saou_households):
|
||||||
|
result = compute_linear_tariff(saou_households, recettes=75000, abop=100, abos=100)
|
||||||
|
# p0 = (recettes - sum_abo) / sum_volume
|
||||||
|
volumes = [max(h.volume_m3, 1e-5) for h in saou_households]
|
||||||
|
total_vol = sum(volumes)
|
||||||
|
expected_p0 = (75000 - 363 * 100) / total_vol # all RS have same abo in this case
|
||||||
|
assert abs(result.p0 - expected_p0) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
class TestMedian:
|
||||||
|
"""Test the median computation."""
|
||||||
|
|
||||||
|
def test_single_vote(self):
|
||||||
|
votes = [VoteParams(vinf=1050, a=0.5, b=0.5, c=0.5, d=0.5, e=0.5)]
|
||||||
|
m = compute_median(votes)
|
||||||
|
assert m.vinf == 1050
|
||||||
|
assert m.a == 0.5
|
||||||
|
|
||||||
|
def test_odd_votes(self):
|
||||||
|
votes = [
|
||||||
|
VoteParams(vinf=800, a=0.3, b=0.2, c=0.4, d=0.5, e=0.6),
|
||||||
|
VoteParams(vinf=1000, a=0.5, b=0.5, c=0.5, d=0.5, e=0.5),
|
||||||
|
VoteParams(vinf=1200, a=0.7, b=0.8, c=0.6, d=0.5, e=0.4),
|
||||||
|
]
|
||||||
|
m = compute_median(votes)
|
||||||
|
assert m.vinf == 1000
|
||||||
|
assert m.a == 0.5
|
||||||
|
|
||||||
|
def test_even_votes(self):
|
||||||
|
votes = [
|
||||||
|
VoteParams(vinf=800, a=0.3, b=0.2, c=0.4, d=0.5, e=0.6),
|
||||||
|
VoteParams(vinf=1200, a=0.7, b=0.8, c=0.6, d=0.5, e=0.4),
|
||||||
|
]
|
||||||
|
m = compute_median(votes)
|
||||||
|
assert m.vinf == 1000 # average of 800, 1200
|
||||||
|
assert abs(m.a - 0.5) < 1e-10
|
||||||
|
|
||||||
|
def test_empty_votes(self):
|
||||||
|
assert compute_median([]) is None
|
||||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite+aiosqlite:///./sejeteralo.db
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||||
|
- DEBUG=false
|
||||||
|
- CORS_ORIGINS=["http://localhost:3000"]
|
||||||
|
volumes:
|
||||||
|
- backend-data:/app
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NUXT_PUBLIC_API_BASE=http://localhost:8000/api/v1
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend-data:
|
||||||
355
eau.py
Normal file
355
eau.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Created on Sat Jun 18 2022
|
||||||
|
Backend for water price computation
|
||||||
|
@author: alex delga
|
||||||
|
"""
|
||||||
|
|
||||||
|
import xlrd
|
||||||
|
import xlwt
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt # main ploting module
|
||||||
|
from matplotlib.widgets import Slider
|
||||||
|
import pandas as pd
|
||||||
|
import seaborn as sns
|
||||||
|
sns.set_theme()
|
||||||
|
|
||||||
|
class NewModel():
|
||||||
|
def __init__(self,pmax=20,vmax=2100): # Set the static parameters
|
||||||
|
self.pmax=pmax #maximum price per m3 acceptable
|
||||||
|
self.vmax=vmax #consumed volume in m3 for which such price is reached. All inhabitants must be under.
|
||||||
|
|
||||||
|
self.nbpts=1023
|
||||||
|
self.tt=np.linspace(0,1-1e-6,self.nbpts) # the -1e-6 is to avoid pb at vv=vmax later
|
||||||
|
self.vv=np.zeros((2*self.nbpts,))#must be reset parametrically using tt everytime a,b,c,d,e,vinf are changed.
|
||||||
|
|
||||||
|
self.abopArray = np.linspace(0,200,201) # potential values for abop
|
||||||
|
self.abosArray = np.linspace(0,200,201) # potential values for abos
|
||||||
|
self.recettesArray = np.linspace (50000,100000,51) # potential values for recettes
|
||||||
|
self.vinfArray = np.linspace(0,vmax,vmax+1) # potential values for volume at inflexion point
|
||||||
|
self.aArray = np.linspace(0,1,51) # potential values for a
|
||||||
|
self.bArray = np.linspace(0,1,51) # potential values for b
|
||||||
|
self.cArray = np.linspace(0,1,51) # potential values for c
|
||||||
|
self.dArray = np.linspace(0,1,51) # potential values for d
|
||||||
|
self.eArray = np.linspace(0,1,51) # potential values for e
|
||||||
|
|
||||||
|
#loading the inhabitant data
|
||||||
|
book = xlrd.open_workbook('Eau2018.xls')
|
||||||
|
sheet = book.sheet_by_name('CALCULS')
|
||||||
|
self.nbHab=363
|
||||||
|
self.volume = np.array([sheet.cell_value(r,4) for r in range(1,self.nbHab+1)])#get the volume used in 2018 in m3
|
||||||
|
self.vTot=np.sum(self.volume)
|
||||||
|
self.volume[self.volume==0]=1e-5 #little trick to avoid divide by 0
|
||||||
|
self.status = np.array([sheet.cell_value(r,3) for r in range(1,self.nbHab+1)])#get the RS, RP, PRO status
|
||||||
|
self.prix2018 = np.array([sheet.cell_value(r,33) for r in range(1,self.nbHab+1)])#get the price paid in 2018 in EUR
|
||||||
|
self.prix2018_m3 = self.prix2018 / self.volume
|
||||||
|
|
||||||
|
|
||||||
|
def mainFunction(self):
|
||||||
|
'''
|
||||||
|
main function, the one called in the notebook, and that update computations depending on the slider values.
|
||||||
|
'''
|
||||||
|
# initial values
|
||||||
|
self.abop= self.abopArray[100]
|
||||||
|
self.abos= self.abosArray[100]
|
||||||
|
self.recettes= self.recettesArray[25]
|
||||||
|
self.vinf=self.vinfArray[int(self.vmax/2)]
|
||||||
|
self.a=self.aArray[25]
|
||||||
|
self.b=self.bArray[25]
|
||||||
|
self.c=self.cArray[25]
|
||||||
|
self.d=self.dArray[25]
|
||||||
|
self.e=self.eArray[25]
|
||||||
|
|
||||||
|
# initial plot
|
||||||
|
self.updateComputation()
|
||||||
|
|
||||||
|
self.fig = plt.figure(figsize=(9,6))
|
||||||
|
self.ax1 = plt.subplot(211)
|
||||||
|
self.ax2 = plt.subplot(212,sharex=self.ax1)
|
||||||
|
#self.ax1.set_xlabel(r'volume ($m^3$) ')
|
||||||
|
self.ax2.set_xlabel(r'volume ($m^3$) ')
|
||||||
|
self.ax1.set_ylabel('Facture totale (€)')
|
||||||
|
self.ax2.set_ylabel('Prix au m3 (€)')
|
||||||
|
|
||||||
|
#default values of ax limits
|
||||||
|
self.axx=(min(self.vv),max(self.vv))
|
||||||
|
self.ax1y=(0,max(self.prixp))
|
||||||
|
self.ax2y=(0,self.pmax)
|
||||||
|
|
||||||
|
self.updatePlot()
|
||||||
|
|
||||||
|
# SLIDERS
|
||||||
|
axcolor = 'lightgoldenrodyellow'
|
||||||
|
abopAxis = plt.axes([0.7, 0.70, 0.2, 0.03], facecolor=axcolor)
|
||||||
|
self.abopSlider = Slider(abopAxis, r'Abo. P/PRO (€)', np.min(self.abopArray), np.max(self.abopArray), self.abop)
|
||||||
|
self.abopSlider.on_changed(self.updateParam)
|
||||||
|
|
||||||
|
abosAxis = plt.axes([0.7, 0.65, 0.2, 0.03], facecolor=axcolor)
|
||||||
|
self.abosSlider = Slider(abosAxis, r'Abo. S (€)', np.min(self.abosArray), np.max(self.abosArray), self.abos)
|
||||||
|
self.abosSlider.on_changed(self.updateParam)
|
||||||
|
|
||||||
|
recettesAxis = plt.axes([0.7, 0.60, 0.2, 0.03], facecolor=axcolor)
|
||||||
|
self.recettesSlider = Slider(recettesAxis, r'Recettes (€)', np.min(self.recettesArray), np.max(self.recettesArray), self.recettes)
|
||||||
|
self.recettesSlider.on_changed(self.updateParam)
|
||||||
|
|
||||||
|
vinfAxis = plt.axes([0.7, 0.55, 0.2, 0.03], facecolor=axcolor)
|
||||||
|
self.vinfSlider = Slider(vinfAxis, r'$v_{0}$', np.min(self.vinfArray), np.max(self.vinfArray), self.vinf)
|
||||||
|
self.vinfSlider.on_changed(self.updateParam)
|
||||||
|
|
||||||
|
aAxis = plt.axes([0.7, 0.5, 0.2, 0.03], facecolor=axcolor)
|
||||||
|
self.aSlider = Slider(aAxis, r'a', np.min(self.aArray), np.max(self.aArray), self.a)
|
||||||
|
self.aSlider.on_changed(self.updateParam)
|
||||||
|
|
||||||
|
bAxis = plt.axes([0.7, 0.45, 0.2, 0.03], facecolor=axcolor)
|
||||||
|
self.bSlider = Slider(bAxis, r'b', np.min(self.bArray), np.max(self.bArray), self.b)
|
||||||
|
self.bSlider.on_changed(self.updateParam)
|
||||||
|
|
||||||
|
cAxis = plt.axes([0.7, 0.4, 0.2, 0.03], facecolor=axcolor)
|
||||||
|
self.cSlider = Slider(cAxis, r'c', np.min(self.cArray), np.max(self.cArray), self.c)
|
||||||
|
self.cSlider.on_changed(self.updateParam)
|
||||||
|
|
||||||
|
dAxis = plt.axes([0.7, 0.35, 0.2, 0.03], facecolor=axcolor)
|
||||||
|
self.dSlider = Slider(dAxis, r'd', np.min(self.dArray), np.max(self.dArray), self.dArray[25])
|
||||||
|
self.dSlider.on_changed(self.updateParam)
|
||||||
|
|
||||||
|
eAxis = plt.axes([0.7, 0.3, 0.2, 0.03], facecolor=axcolor)
|
||||||
|
self.eSlider = Slider(eAxis, r'e', np.min(self.eArray), np.max(self.eArray), self.eArray[25])
|
||||||
|
self.eSlider.on_changed(self.updateParam)
|
||||||
|
|
||||||
|
def updateComputation(self): # The computation, parameter dependant
|
||||||
|
#first deal with abonnements
|
||||||
|
mask= self.status=='RS'
|
||||||
|
self.abo= self.abop*np.ones((self.nbHab,))
|
||||||
|
self.abo[self.status=='RS']=self.abos
|
||||||
|
|
||||||
|
#second deal with consumption and total prices
|
||||||
|
self.prixConso=np.zeros(self.vv.shape)
|
||||||
|
self.prixp=np.zeros(self.vv.shape)
|
||||||
|
self.prixs=np.zeros(self.vv.shape)
|
||||||
|
if np.sum(self.abo)>=self.recettes:
|
||||||
|
self.p0=0
|
||||||
|
self.prixp = self.abop
|
||||||
|
self.prixs = self.abos
|
||||||
|
else:
|
||||||
|
#find p0 to balance income.
|
||||||
|
alpha1=np.zeros((self.nbHab,))# préfacteur de p_0 dans la tranche 1
|
||||||
|
alpha2=np.zeros((self.nbHab,))# préfacteur de p_0 dans la tranche 2
|
||||||
|
beta2=np.zeros((self.nbHab,))# constante dans la tranche 2
|
||||||
|
|
||||||
|
#compute consumption integral on all inhabitants
|
||||||
|
for ih in range(self.nbHab):
|
||||||
|
alpha1[ih],alpha2[ih],beta2[ih]=self.computeIntegrals(self.volume[ih])
|
||||||
|
|
||||||
|
self.p0=(self.recettes-np.sum(beta2+self.abo))/np.sum(alpha1+alpha2) # prix d'inflexion
|
||||||
|
self.prixProj = (alpha1+alpha2)*self.p0+beta2 #Calcul du prix payé par les habitantes dans le nouveau scénario
|
||||||
|
|
||||||
|
#compute consumption price per m3
|
||||||
|
#tranche 1
|
||||||
|
self.vv[0:self.nbpts]=self.vinf*((1-3*self.b)*(self.tt**3)+3*self.b*(self.tt**2))
|
||||||
|
self.prixConso[0:self.nbpts]=self.p0*((3*self.a-2)*(self.tt**3)+(-6*self.a+3)*(self.tt**2)+3*self.a*self.tt)
|
||||||
|
#tranche 2
|
||||||
|
self.vv[self.nbpts:]=self.vinf+(self.vmax-self.vinf)*((3*(self.c+self.d-self.c*self.d)-2)*(self.tt**3)+\
|
||||||
|
3*(1-2*self.c-self.d+self.c*self.d)*(self.tt**2)+3*self.c*self.tt)
|
||||||
|
self.prixConso[self.nbpts:]=self.p0+(self.pmax-self.p0)*((1-3*self.e)*(self.tt**3)+3*self.e*(self.tt**2))
|
||||||
|
|
||||||
|
#compute full consumption price (integral of price per m3)
|
||||||
|
alpha1=np.zeros(self.vv.shape)# préfacteur de p_0 dans la tranche 1
|
||||||
|
alpha2=np.zeros(self.vv.shape)# préfacteur de p_0 dans la tranche 2
|
||||||
|
beta2=np.zeros(self.vv.shape)# constante dans la tranche 2
|
||||||
|
for iv,vv in enumerate(self.vv):
|
||||||
|
alpha1[iv],alpha2[iv],beta2[iv]=self.computeIntegrals(vv)
|
||||||
|
self.prixp = self.abop+(alpha1+alpha2)*self.p0+beta2
|
||||||
|
self.prixs = self.abos+(alpha1+alpha2)*self.p0+beta2
|
||||||
|
|
||||||
|
#now compute prices
|
||||||
|
self.prixp_m3 = self.prixp/self.vv
|
||||||
|
self.prixs_m3 = self.prixs/self.vv
|
||||||
|
|
||||||
|
def computeIntegrals(self,vv):#return alpha1, alpha2 and beta for an input volume
|
||||||
|
a=self.a
|
||||||
|
b=self.b
|
||||||
|
c=self.c
|
||||||
|
d=self.d
|
||||||
|
e=self.e
|
||||||
|
if vv<=self.vinf:# on ne travaille que dans la tranche 1.
|
||||||
|
#Find the value of T by solving the equation V=v(T)
|
||||||
|
if vv==0: #remove extrema points where root finding can be problematic
|
||||||
|
T=0.0
|
||||||
|
elif vv==self.vinf:
|
||||||
|
T=1.0
|
||||||
|
else:
|
||||||
|
p = [1-3*b, 3*b, 0, -vv/self.vinf]# polynomial representation of the equation
|
||||||
|
roots = np.roots(p)
|
||||||
|
roots = np.unique(roots)#remove duplicates
|
||||||
|
roots2 = np.real(roots[np.isreal(roots)]) # take only real roots
|
||||||
|
mask = (roots2<=1.) & (roots2>=0.)#check that T is real and between 0 and 1
|
||||||
|
T=float(roots2[mask])
|
||||||
|
alpha1=3*self.vinf*(T**6/6*(-9*a*b+3*a+6*b-2)+T**5/5*(24*a*b-6*a-13*b+3)+3*T**4/4*(-7*a*b+a+2*b)+T**3/3*6*a*b)
|
||||||
|
return alpha1,0,0
|
||||||
|
else:
|
||||||
|
alpha1=3*self.vinf*(1/6*(-9*a*b+3*a+6*b-2)+1/5*(24*a*b-6*a-13*b+3)+3/4*(-7*a*b+a+2*b)+1/3*6*a*b)# tranche 1 avec T=1
|
||||||
|
#tranche 2
|
||||||
|
wmax=self.vmax-self.vinf
|
||||||
|
#Find the value of T by solving the equation V-vinf=w(T)
|
||||||
|
if vv==self.vinf: #remove extrema points where root finding can be problematic
|
||||||
|
T=0.0
|
||||||
|
elif vv==self.vmax:
|
||||||
|
T=1.0
|
||||||
|
else:
|
||||||
|
p = [3*(c+d-c*d)-2, 3*(1-2*c-d+c*d), 3*c, -(vv-self.vinf)/wmax]
|
||||||
|
roots = np.roots(p)
|
||||||
|
roots = np.unique(roots)#remove duplicates
|
||||||
|
roots2 = np.real(roots[np.isreal(roots)]) # take only real roots
|
||||||
|
mask = (roots2<=1.) & (roots2>=0.)#check that T is real and between 0 and 1#
|
||||||
|
if not mask.any(): # for a weird reason, if vv=vmax, we have roots2=1.0 but it does not pass roots2<=1.0 ????
|
||||||
|
print(vv,roots,roots2,mask)
|
||||||
|
T=float(np.real(roots2[mask]))
|
||||||
|
uu=(-3*c*d+9*e*c*d+3*c-9*e*c+3*d-9*e*d+6*e -2)*T**6/6+(2*c*d-15*e*c*d-4*c+21*e*c-2*d+15*e*d-12*e+2)*T**5/5 +(6*e*c*d+c-15*e*c-6*e*d+6*e)*T**4/4+(3*e*c)*T**3/3
|
||||||
|
alpha2=vv-self.vinf-3*uu*wmax
|
||||||
|
beta2=3*self.pmax*wmax*uu
|
||||||
|
return alpha1,alpha2,beta2
|
||||||
|
|
||||||
|
def updatePlot(self): # The plot update
|
||||||
|
self.ax1.plot(self.vv,self.prixp,label='RP/PRO')
|
||||||
|
self.ax1.plot(self.vv,self.prixs,label='RS')
|
||||||
|
self.ax1.scatter(self.volume,self.prix2018,s=3,color='g',label='2018')
|
||||||
|
self.ax2.plot(self.vv,self.prixp_m3,label='RP/PRO')
|
||||||
|
self.ax2.plot(self.vv,self.prixs_m3,label='RS')
|
||||||
|
self.ax2.plot(self.vv,self.prixConso,'k-',label='Conso')
|
||||||
|
self.ax2.plot([self.vinf,self.vinf],[0,self.p0],color = '0.75')
|
||||||
|
self.ax2.plot([0,self.vinf],[self.p0,self.p0],color = '0.75')
|
||||||
|
self.ax2.scatter(self.volume,self.prix2018_m3,s=3,color='g',label='2018')
|
||||||
|
|
||||||
|
self.ax1.set_xlim(self.axx)
|
||||||
|
self.ax1.set_ylim(self.ax1y)
|
||||||
|
self.ax2.set_ylim(self.ax2y)
|
||||||
|
#self.ax2.text(0.9, 0.2, '$p_0=${:.2f} €'.format(self.p0),ha='right', va='bottom', transform=self.ax2.transAxes)
|
||||||
|
self.ax2.legend()
|
||||||
|
self.ax2.set_xlabel(r'volume ($m^3$) ')
|
||||||
|
self.ax1.set_ylabel('Facture totale (€)')
|
||||||
|
self.ax2.set_ylabel('Prix au m3 (€)')
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.subplots_adjust(top = 0.9,right = 0.5)
|
||||||
|
|
||||||
|
def updateParam(self,val): # Update the parameter using the slider values
|
||||||
|
#get current values of ax limits
|
||||||
|
self.axx=self.ax1.get_xlim()
|
||||||
|
self.ax1y=self.ax1.get_ylim()
|
||||||
|
self.ax2y=self.ax2.get_ylim()
|
||||||
|
self.ax1.clear()
|
||||||
|
self.ax2.clear()
|
||||||
|
self.abop= self.abopSlider.val
|
||||||
|
self.abos= self.abosSlider.val
|
||||||
|
self.recettes= self.recettesSlider.val
|
||||||
|
self.vinf=self.vinfSlider.val
|
||||||
|
self.a=self.aSlider.val
|
||||||
|
self.b=self.bSlider.val
|
||||||
|
self.c=self.cSlider.val
|
||||||
|
self.d=self.dSlider.val
|
||||||
|
self.e=self.eSlider.val
|
||||||
|
|
||||||
|
self.updateComputation()
|
||||||
|
self.updatePlot() #
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentModel():
|
||||||
|
def __init__(self): # Set the static parameters
|
||||||
|
|
||||||
|
self.vv = np.linspace(0,2100,2101) # volume values
|
||||||
|
self.vv[0]=1e-5 #little trick to avoid divide by 0
|
||||||
|
self.abopArray = np.linspace(0,200,201) # potential values for abop
|
||||||
|
self.abosArray = np.linspace(0,200,201) # potential values for abos
|
||||||
|
self.recettesArray = np.linspace (50000,100000,51) # potential values for recettes
|
||||||
|
|
||||||
|
#loading the inhabitant data
|
||||||
|
book = xlrd.open_workbook('Eau2018.xls')
|
||||||
|
sheet = book.sheet_by_name('CALCULS')
|
||||||
|
self.nbHab=363
|
||||||
|
self.volume = np.array([sheet.cell_value(r,4) for r in range(1,self.nbHab+1)])#get the volume used in 2018 in m3
|
||||||
|
self.vTot=np.sum(self.volume)
|
||||||
|
self.volume[self.volume==0]=1e-5 #little trick to avoid divide by 0
|
||||||
|
self.status = np.array([sheet.cell_value(r,3) for r in range(1,self.nbHab+1)])#get the RS, RP, PRO status
|
||||||
|
self.prix2018 = np.array([sheet.cell_value(r,33) for r in range(1,self.nbHab+1)])#get the price paid in 2018 in EUR
|
||||||
|
self.prix2018_m3 = self.prix2018 / self.volume
|
||||||
|
|
||||||
|
|
||||||
|
def mainFunction(self):
|
||||||
|
'''
|
||||||
|
main function, the one called in the notebook, and that update computations depending on the slider values.
|
||||||
|
'''
|
||||||
|
self.fig = plt.figure(figsize=(8,5))
|
||||||
|
self.ax1 = plt.subplot(121)
|
||||||
|
self.ax2 = plt.subplot(122,sharex=self.ax1)
|
||||||
|
self.ax1.set_xlabel(r'volume ($m^3$) ')
|
||||||
|
self.ax2.set_xlabel(r'volume ($m^3$) ')
|
||||||
|
self.ax1.set_ylabel('Facture totale (€)')
|
||||||
|
self.ax2.set_ylabel('Prix au m3 (€)')
|
||||||
|
|
||||||
|
# initial values
|
||||||
|
self.abop= self.abopArray[100]
|
||||||
|
self.abos= self.abosArray[100]
|
||||||
|
self.recettes= self.recettesArray[25]
|
||||||
|
|
||||||
|
# initial plot
|
||||||
|
self.updateComputation()
|
||||||
|
self.updatePlot()
|
||||||
|
|
||||||
|
# SLIDERS
|
||||||
|
axcolor = 'lightgoldenrodyellow'
|
||||||
|
abopAxis = plt.axes([0.2, 0.15, 0.65, 0.05], facecolor=axcolor)
|
||||||
|
self.abopSlider = Slider(abopAxis, r'Abo. P/PRO (EUR)', np.min(self.abopArray), np.max(self.abopArray), self.abopArray[100])
|
||||||
|
self.abopSlider.on_changed(self.updateParam)
|
||||||
|
abosAxis = plt.axes([0.2, 0.1, 0.65, 0.05], facecolor=axcolor)
|
||||||
|
self.abosSlider = Slider(abosAxis, r'Abo. S (EUR)', np.min(self.abosArray), np.max(self.abosArray), self.abosArray[100])
|
||||||
|
self.abosSlider.on_changed(self.updateParam)
|
||||||
|
recettesAxis = plt.axes([0.2, 0.05, 0.65, 0.05], facecolor=axcolor)
|
||||||
|
self.recettesSlider = Slider(recettesAxis, r'Recettes (EUR)', np.min(self.recettesArray), np.max(self.recettesArray), self.recettesArray[25])
|
||||||
|
self.recettesSlider.on_changed(self.updateParam)
|
||||||
|
|
||||||
|
def updateComputation(self): # The computation, parameter dependant
|
||||||
|
#first deal with abonnements
|
||||||
|
mask= self.status=='RS'
|
||||||
|
self.abo= self.abop*np.ones((self.nbHab,))
|
||||||
|
self.abo[self.status=='RS']=self.abos
|
||||||
|
|
||||||
|
#second deal with consumption
|
||||||
|
if np.sum(self.abo)>=self.recettes:
|
||||||
|
self.p0=0
|
||||||
|
else:
|
||||||
|
self.p0= (self.recettes-np.sum(self.abo))/self.vTot
|
||||||
|
|
||||||
|
#now deal with prices
|
||||||
|
self.prixp = self.abop+self.p0*self.vv
|
||||||
|
self.prixs = self.abos+self.p0*self.vv
|
||||||
|
self.prixp_m3 = self.abop/self.vv+self.p0
|
||||||
|
self.prixs_m3 = self.abos/self.vv+self.p0
|
||||||
|
|
||||||
|
def updatePlot(self): # The plot update
|
||||||
|
self.ax1.plot(self.vv,self.prixp,label='RP/PRO')
|
||||||
|
self.ax1.plot(self.vv,self.prixs,label='RS')
|
||||||
|
self.ax1.scatter(self.volume,self.prix2018,s=3,color='g',label='2018')
|
||||||
|
|
||||||
|
self.ax2.plot(self.vv,self.prixp_m3,label='RP/PRO')
|
||||||
|
self.ax2.plot(self.vv,self.prixs_m3,label='RS')
|
||||||
|
self.ax2.scatter(self.volume,self.prix2018_m3,s=3,color='g',label='2018')
|
||||||
|
self.ax2.set_ylim(0,20)
|
||||||
|
self.ax2.text(0.9, 0.2, '$p_0=${:.2f} €'.format(self.p0),ha='right', va='bottom', transform=self.ax2.transAxes)
|
||||||
|
self.ax2.legend()
|
||||||
|
|
||||||
|
self.ax1.set_xlabel(r'volume ($m^3$) ')
|
||||||
|
self.ax2.set_xlabel(r'volume ($m^3$) ')
|
||||||
|
self.ax1.set_ylabel('Facture totale (€)')
|
||||||
|
self.ax2.set_ylabel('Prix au m3 (€)')
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.subplots_adjust(top = 0.9,bottom = 0.35)
|
||||||
|
|
||||||
|
def updateParam(self,val): # Update the parameter using the slider values
|
||||||
|
self.ax1.clear()
|
||||||
|
self.ax2.clear()
|
||||||
|
self.abop= self.abopSlider.val
|
||||||
|
self.abos= self.abosSlider.val
|
||||||
|
self.recettes= self.recettesSlider.val
|
||||||
|
self.updateComputation()
|
||||||
|
self.updatePlot() #
|
||||||
|
|
||||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
13
frontend/app/app.vue
Normal file
13
frontend/app/app.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
authStore.restore()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
230
frontend/app/assets/css/main.css
Normal file
230
frontend/app/assets/css/main.css
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/* SejeteralO - Global Styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-primary: #2563eb;
|
||||||
|
--color-primary-dark: #1d4ed8;
|
||||||
|
--color-secondary: #059669;
|
||||||
|
--color-accent: #d97706;
|
||||||
|
--color-danger: #dc2626;
|
||||||
|
--color-bg: #f8fafc;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-text: #1e293b;
|
||||||
|
--color-text-muted: #64748b;
|
||||||
|
--color-border: #e2e8f0;
|
||||||
|
--radius: 8px;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-2, .grid-3, .grid-4 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e40af;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #166534;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-green {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-blue {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-amber {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
|
.spinner {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
518
frontend/app/components/BezierEditor.vue
Normal file
518
frontend/app/components/BezierEditor.vue
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bezier-editor">
|
||||||
|
<div class="editor-layout">
|
||||||
|
<!-- SVG Canvas -->
|
||||||
|
<div class="editor-canvas card">
|
||||||
|
<svg
|
||||||
|
ref="svgRef"
|
||||||
|
:viewBox="`0 0 ${svgW} ${svgH}`"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
@mousemove="onMouseMove"
|
||||||
|
@mouseup="onMouseUp"
|
||||||
|
@mouseleave="onMouseUp"
|
||||||
|
@touchmove.prevent="onTouchMove"
|
||||||
|
@touchend="onMouseUp"
|
||||||
|
>
|
||||||
|
<!-- Grid -->
|
||||||
|
<g class="grid-lines">
|
||||||
|
<line
|
||||||
|
v-for="v in gridVolumes"
|
||||||
|
:key="'gv' + v"
|
||||||
|
:x1="toSvgX(v)" :y1="toSvgY(0)" :x2="toSvgX(v)" :y2="toSvgY(pmax)"
|
||||||
|
stroke="#e2e8f0" stroke-width="0.5"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
v-for="p in gridPrices"
|
||||||
|
:key="'gp' + p"
|
||||||
|
:x1="toSvgX(0)" :y1="toSvgY(p)" :x2="toSvgX(vmax)" :y2="toSvgY(p)"
|
||||||
|
stroke="#e2e8f0" stroke-width="0.5"
|
||||||
|
/>
|
||||||
|
<!-- Axis labels -->
|
||||||
|
<text
|
||||||
|
v-for="v in gridVolumes"
|
||||||
|
:key="'lv' + v"
|
||||||
|
:x="toSvgX(v)" :y="svgH - 2"
|
||||||
|
text-anchor="middle" font-size="10" fill="#94a3b8"
|
||||||
|
>{{ v }}</text>
|
||||||
|
<text
|
||||||
|
v-for="p in gridPrices"
|
||||||
|
:key="'lp' + p"
|
||||||
|
:x="4" :y="toSvgY(p) + 3"
|
||||||
|
font-size="10" fill="#94a3b8"
|
||||||
|
>{{ p }}</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Control point tangent lines -->
|
||||||
|
<g class="tangent-lines">
|
||||||
|
<line :x1="toSvgX(cp.p1.x)" :y1="toSvgY(cp.p1.y)" :x2="toSvgX(cp.p2.x)" :y2="toSvgY(cp.p2.y)"
|
||||||
|
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
|
||||||
|
<line :x1="toSvgX(cp.p3.x)" :y1="toSvgY(cp.p3.y)" :x2="toSvgX(cp.p4.x)" :y2="toSvgY(cp.p4.y)"
|
||||||
|
stroke="#93c5fd" stroke-width="1" stroke-dasharray="4" />
|
||||||
|
<line :x1="toSvgX(cp.p4.x)" :y1="toSvgY(cp.p4.y)" :x2="toSvgX(cp.p5.x)" :y2="toSvgY(cp.p5.y)"
|
||||||
|
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
|
||||||
|
<line :x1="toSvgX(cp.p6.x)" :y1="toSvgY(cp.p6.y)" :x2="toSvgX(cp.p7.x)" :y2="toSvgY(cp.p7.y)"
|
||||||
|
stroke="#fca5a5" stroke-width="1" stroke-dasharray="4" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bézier curves -->
|
||||||
|
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="2.5" />
|
||||||
|
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2.5" />
|
||||||
|
|
||||||
|
<!-- Inflection point lines -->
|
||||||
|
<line :x1="toSvgX(params.vinf)" :y1="toSvgY(0)" :x2="toSvgX(params.vinf)" :y2="toSvgY(localP0)"
|
||||||
|
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
||||||
|
<line :x1="toSvgX(0)" :y1="toSvgY(localP0)" :x2="toSvgX(params.vinf)" :y2="toSvgY(localP0)"
|
||||||
|
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
||||||
|
|
||||||
|
<!-- p0 label -->
|
||||||
|
<text :x="toSvgX(0) + 25" :y="toSvgY(localP0) - 6" font-size="12" fill="#1e293b" font-weight="600">
|
||||||
|
p₀ = {{ localP0.toFixed(2) }} €/m³
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Draggable control points -->
|
||||||
|
<circle
|
||||||
|
v-for="(point, key) in draggablePoints"
|
||||||
|
:key="key"
|
||||||
|
:cx="toSvgX(point.x)"
|
||||||
|
:cy="toSvgY(point.y)"
|
||||||
|
:r="dragging === key ? 8 : 6"
|
||||||
|
:fill="pointColors[key]"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="2"
|
||||||
|
style="cursor: grab;"
|
||||||
|
@mousedown.prevent="startDrag(key, $event)"
|
||||||
|
@touchstart.prevent="startDragTouch(key, $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Point labels -->
|
||||||
|
<text
|
||||||
|
v-for="(point, key) in draggablePoints"
|
||||||
|
:key="'label-' + key"
|
||||||
|
:x="toSvgX(point.x) + 10"
|
||||||
|
:y="toSvgY(point.y) - 10"
|
||||||
|
font-size="11"
|
||||||
|
:fill="pointColors[key]"
|
||||||
|
font-weight="500"
|
||||||
|
>{{ pointLabels[key] }}</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right panel -->
|
||||||
|
<div class="editor-panel">
|
||||||
|
<!-- Parameters display -->
|
||||||
|
<div class="card" style="margin-bottom: 1rem;">
|
||||||
|
<h3 style="margin-bottom: 0.75rem;">Paramètres</h3>
|
||||||
|
<div class="param-grid">
|
||||||
|
<div class="param-item">
|
||||||
|
<span class="param-label">vinf</span>
|
||||||
|
<span class="param-value">{{ params.vinf.toFixed(0) }} m³</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<span class="param-label">a</span>
|
||||||
|
<span class="param-value">{{ params.a.toFixed(3) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<span class="param-label">b</span>
|
||||||
|
<span class="param-value">{{ params.b.toFixed(3) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<span class="param-label">c</span>
|
||||||
|
<span class="param-value">{{ params.c.toFixed(3) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<span class="param-label">d</span>
|
||||||
|
<span class="param-value">{{ params.d.toFixed(3) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<span class="param-label">e</span>
|
||||||
|
<span class="param-value">{{ params.e.toFixed(3) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-item" style="grid-column: span 2;">
|
||||||
|
<span class="param-label">p₀ (prix inflexion)</span>
|
||||||
|
<span class="param-value" style="font-size: 1.25rem;">{{ localP0.toFixed(2) }} €/m³</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Impact table -->
|
||||||
|
<div class="card" style="margin-bottom: 1rem;">
|
||||||
|
<h3 style="margin-bottom: 0.75rem;">Impact par volume</h3>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Volume</th>
|
||||||
|
<th>Ancien prix</th>
|
||||||
|
<th>Nouveau (RP)</th>
|
||||||
|
<th>Nouveau (RS)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="imp in impacts" :key="imp.volume">
|
||||||
|
<td>{{ imp.volume }} m³</td>
|
||||||
|
<td>{{ imp.oldPrice.toFixed(2) }} €</td>
|
||||||
|
<td :class="imp.newPriceRP > imp.oldPrice ? 'text-up' : 'text-down'">
|
||||||
|
{{ imp.newPriceRP.toFixed(2) }} €
|
||||||
|
</td>
|
||||||
|
<td :class="imp.newPriceRS > imp.oldPrice ? 'text-up' : 'text-down'">
|
||||||
|
{{ imp.newPriceRS.toFixed(2) }} €
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit vote -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
computeP0, generateCurve, computeImpacts,
|
||||||
|
paramsToControlPoints, controlPointsToParams,
|
||||||
|
type HouseholdData, type ImpactRow, type ControlPoints,
|
||||||
|
} from '~/utils/bezier-math'
|
||||||
|
|
||||||
|
const props = defineProps<{ communeSlug: string }>()
|
||||||
|
const emit = defineEmits<{ 'vote-submitted': [] }>()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
// SVG dimensions
|
||||||
|
const svgW = 600
|
||||||
|
const svgH = 400
|
||||||
|
const margin = { top: 20, right: 20, bottom: 30, left: 35 }
|
||||||
|
const plotW = svgW - margin.left - margin.right
|
||||||
|
const plotH = svgH - margin.top - margin.bottom
|
||||||
|
|
||||||
|
// Commune tariff params (fixed by admin)
|
||||||
|
const vmax = ref(2100)
|
||||||
|
const pmax = ref(20)
|
||||||
|
const recettes = ref(75000)
|
||||||
|
const abop = ref(100)
|
||||||
|
const abos = ref(100)
|
||||||
|
const households = ref<HouseholdData[]>([])
|
||||||
|
|
||||||
|
// Citizen-adjustable params
|
||||||
|
const params = reactive({
|
||||||
|
vinf: 1050,
|
||||||
|
a: 0.5,
|
||||||
|
b: 0.5,
|
||||||
|
c: 0.5,
|
||||||
|
d: 0.5,
|
||||||
|
e: 0.5,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const localP0 = ref(0)
|
||||||
|
const impacts = ref<ImpactRow[]>([])
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const cp = computed<ControlPoints>(() =>
|
||||||
|
paramsToControlPoints(params.vinf, vmax.value, pmax.value, localP0.value, params.a, params.b, params.c, params.d, params.e)
|
||||||
|
)
|
||||||
|
|
||||||
|
const draggablePoints = computed(() => ({
|
||||||
|
p2: cp.value.p2,
|
||||||
|
p3: cp.value.p3,
|
||||||
|
p4: cp.value.p4,
|
||||||
|
p5: cp.value.p5,
|
||||||
|
p6: cp.value.p6,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const pointColors: Record<string, string> = {
|
||||||
|
p2: '#3b82f6',
|
||||||
|
p3: '#3b82f6',
|
||||||
|
p4: '#8b5cf6',
|
||||||
|
p5: '#ef4444',
|
||||||
|
p6: '#ef4444',
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointLabels: Record<string, string> = {
|
||||||
|
p2: 'P₂ (a)',
|
||||||
|
p3: 'P₃ (b)',
|
||||||
|
p4: 'P₄ (vinf)',
|
||||||
|
p5: 'P₅ (c)',
|
||||||
|
p6: 'P₆ (d,e)',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
const gridVolumes = computed(() => {
|
||||||
|
const step = Math.ceil(vmax.value / 7 / 100) * 100
|
||||||
|
const arr = []
|
||||||
|
for (let v = step; v < vmax.value; v += step) arr.push(v)
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
|
||||||
|
const gridPrices = computed(() => {
|
||||||
|
const step = Math.ceil(pmax.value / 5)
|
||||||
|
const arr = []
|
||||||
|
for (let p = step; p < pmax.value; p += step) arr.push(p)
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
|
||||||
|
// Coordinate transforms
|
||||||
|
function toSvgX(v: number): number {
|
||||||
|
return margin.left + (v / vmax.value) * plotW
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSvgY(p: number): number {
|
||||||
|
return margin.top + plotH - (p / pmax.value) * plotH
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromSvgX(sx: number): number {
|
||||||
|
return ((sx - margin.left) / plotW) * vmax.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromSvgY(sy: number): number {
|
||||||
|
return ((margin.top + plotH - sy) / plotH) * pmax.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bézier path generation
|
||||||
|
const tier1Path = computed(() => {
|
||||||
|
const c = cp.value
|
||||||
|
return `M ${toSvgX(c.p1.x)} ${toSvgY(c.p1.y)} C ${toSvgX(c.p2.x)} ${toSvgY(c.p2.y)}, ${toSvgX(c.p3.x)} ${toSvgY(c.p3.y)}, ${toSvgX(c.p4.x)} ${toSvgY(c.p4.y)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const tier2Path = computed(() => {
|
||||||
|
const c = cp.value
|
||||||
|
return `M ${toSvgX(c.p4.x)} ${toSvgY(c.p4.y)} C ${toSvgX(c.p5.x)} ${toSvgY(c.p5.y)}, ${toSvgX(c.p6.x)} ${toSvgY(c.p6.y)}, ${toSvgX(c.p7.x)} ${toSvgY(c.p7.y)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drag handling
|
||||||
|
const svgRef = ref<SVGSVGElement | null>(null)
|
||||||
|
const dragging = ref<string | null>(null)
|
||||||
|
|
||||||
|
function getSvgPoint(event: MouseEvent | Touch): { x: number; y: number } {
|
||||||
|
if (!svgRef.value) return { x: 0, y: 0 }
|
||||||
|
const rect = svgRef.value.getBoundingClientRect()
|
||||||
|
const scaleX = svgW / rect.width
|
||||||
|
const scaleY = svgH / rect.height
|
||||||
|
return {
|
||||||
|
x: (event.clientX - rect.left) * scaleX,
|
||||||
|
y: (event.clientY - rect.top) * scaleY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(key: string, event: MouseEvent) {
|
||||||
|
dragging.value = key
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDragTouch(key: string, event: TouchEvent) {
|
||||||
|
dragging.value = key
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(event: MouseEvent) {
|
||||||
|
if (!dragging.value) return
|
||||||
|
handleDrag(getSvgPoint(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(event: TouchEvent) {
|
||||||
|
if (!dragging.value || !event.touches[0]) return
|
||||||
|
handleDrag(getSvgPoint(event.touches[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrag(svgPoint: { x: number; y: number }) {
|
||||||
|
const v = Math.max(0, Math.min(vmax.value, fromSvgX(svgPoint.x)))
|
||||||
|
const p = Math.max(0, Math.min(pmax.value, fromSvgY(svgPoint.y)))
|
||||||
|
|
||||||
|
switch (dragging.value) {
|
||||||
|
case 'p2': // vertical only → a
|
||||||
|
params.a = localP0.value > 0 ? Math.max(0, Math.min(1, p / localP0.value)) : 0.5
|
||||||
|
break
|
||||||
|
case 'p3': // horizontal only → b
|
||||||
|
params.b = params.vinf > 0 ? Math.max(0, Math.min(1, v / params.vinf)) : 0.5
|
||||||
|
break
|
||||||
|
case 'p4': // horizontal → vinf
|
||||||
|
params.vinf = Math.max(1, Math.min(vmax.value - 1, v))
|
||||||
|
break
|
||||||
|
case 'p5': { // horizontal only → c
|
||||||
|
const wmax = vmax.value - params.vinf
|
||||||
|
params.c = wmax > 0 ? Math.max(0, Math.min(1, (v - params.vinf) / wmax)) : 0.5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'p6': { // 2D → d, e
|
||||||
|
const wmax = vmax.value - params.vinf
|
||||||
|
const qmax = pmax.value - localP0.value
|
||||||
|
|
||||||
|
// e from y
|
||||||
|
params.e = qmax > 0 ? Math.max(0, Math.min(1, (p - localP0.value) / qmax)) : 0.5
|
||||||
|
|
||||||
|
// d from x: x6 = vinf + wmax*(1-d+cd) => d = (1 - (x6-vinf)/wmax)/(1-c)
|
||||||
|
if (wmax > 0 && Math.abs(1 - params.c) > 1e-10) {
|
||||||
|
const ratio = (v - params.vinf) / wmax
|
||||||
|
params.d = Math.max(0, Math.min(1, (1 - ratio) / (1 - params.c)))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recalculate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
if (dragging.value) {
|
||||||
|
dragging.value = null
|
||||||
|
debouncedServerCompute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate locally
|
||||||
|
function recalculate() {
|
||||||
|
if (households.value.length === 0) return
|
||||||
|
|
||||||
|
localP0.value = computeP0(
|
||||||
|
households.value, recettes.value, abop.value, abos.value,
|
||||||
|
params.vinf, vmax.value, pmax.value,
|
||||||
|
params.a, params.b, params.c, params.d, params.e,
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = computeImpacts(
|
||||||
|
households.value, recettes.value, abop.value, abos.value,
|
||||||
|
params.vinf, vmax.value, pmax.value,
|
||||||
|
params.a, params.b, params.c, params.d, params.e,
|
||||||
|
)
|
||||||
|
impacts.value = result.impacts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced server compute
|
||||||
|
let serverTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
function debouncedServerCompute() {
|
||||||
|
if (serverTimeout) clearTimeout(serverTimeout)
|
||||||
|
serverTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.post<any>('/tariff/compute', {
|
||||||
|
commune_slug: props.communeSlug,
|
||||||
|
vinf: params.vinf,
|
||||||
|
a: params.a,
|
||||||
|
b: params.b,
|
||||||
|
c: params.c,
|
||||||
|
d: params.d,
|
||||||
|
e: params.e,
|
||||||
|
})
|
||||||
|
// Use authoritative server p0
|
||||||
|
localP0.value = result.p0
|
||||||
|
impacts.value = result.impacts.map((imp: any) => ({
|
||||||
|
volume: imp.volume,
|
||||||
|
oldPrice: imp.old_price,
|
||||||
|
newPriceRP: imp.new_price_rp,
|
||||||
|
newPriceRS: imp.new_price_rs,
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fall back to client-side calculation
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit vote
|
||||||
|
async function submitVote() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await api.post(`/communes/${props.communeSlug}/votes`, {
|
||||||
|
vinf: params.vinf,
|
||||||
|
a: params.a,
|
||||||
|
b: params.b,
|
||||||
|
c: params.c,
|
||||||
|
d: params.d,
|
||||||
|
e: params.e,
|
||||||
|
})
|
||||||
|
emit('vote-submitted')
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.message || 'Erreur lors de la soumission du vote')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load data on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// Load commune params
|
||||||
|
const communeParams = await api.get<any>(`/communes/${props.communeSlug}/params`)
|
||||||
|
vmax.value = communeParams.vmax
|
||||||
|
pmax.value = communeParams.pmax
|
||||||
|
recettes.value = communeParams.recettes
|
||||||
|
abop.value = communeParams.abop
|
||||||
|
abos.value = communeParams.abos
|
||||||
|
params.vinf = communeParams.vmax / 2
|
||||||
|
|
||||||
|
// Load household stats (we need volumes for p0 calculation)
|
||||||
|
// For client-side compute, we fetch stats and create a simplified model
|
||||||
|
const stats = await api.get<any>(`/communes/${props.communeSlug}/households/stats`)
|
||||||
|
|
||||||
|
// Create representative household distribution for client-side compute
|
||||||
|
// (simplified: use average volumes by status)
|
||||||
|
const rsCount = stats.rs_count || 0
|
||||||
|
const rpCount = stats.rp_count || 0
|
||||||
|
const proCount = stats.pro_count || 0
|
||||||
|
const avgVol = stats.avg_volume || 90
|
||||||
|
|
||||||
|
const hh: HouseholdData[] = []
|
||||||
|
for (let i = 0; i < rsCount; i++) hh.push({ volume_m3: avgVol, status: 'RS' })
|
||||||
|
for (let i = 0; i < rpCount; i++) hh.push({ volume_m3: avgVol, status: 'RP' })
|
||||||
|
for (let i = 0; i < proCount; i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
|
||||||
|
households.value = hh
|
||||||
|
|
||||||
|
// Initial server compute for accurate p0
|
||||||
|
recalculate()
|
||||||
|
debouncedServerCompute()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading commune data:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.editor-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 350px;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.editor-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-canvas {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-canvas svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-value {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-up { color: #dc2626; }
|
||||||
|
.text-down { color: #059669; }
|
||||||
|
</style>
|
||||||
112
frontend/app/components/charts/VoteOverlayChart.vue
Normal file
112
frontend/app/components/charts/VoteOverlayChart.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overlay-chart">
|
||||||
|
<svg :viewBox="`0 0 ${svgW} ${svgH}`" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<!-- Grid -->
|
||||||
|
<g>
|
||||||
|
<line
|
||||||
|
v-for="v in gridVolumes"
|
||||||
|
:key="'gv' + v"
|
||||||
|
:x1="toSvgX(v)" :y1="toSvgY(0)" :x2="toSvgX(v)" :y2="toSvgY(pmax)"
|
||||||
|
stroke="#e2e8f0" stroke-width="0.5"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
v-for="p in gridPrices"
|
||||||
|
:key="'gp' + p"
|
||||||
|
:x1="toSvgX(0)" :y1="toSvgY(p)" :x2="toSvgX(vmax)" :y2="toSvgY(p)"
|
||||||
|
stroke="#e2e8f0" stroke-width="0.5"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Vote curves (semi-transparent) -->
|
||||||
|
<g v-for="(vote, i) in votes" :key="i">
|
||||||
|
<path :d="getVotePath(vote, 1)" fill="none" stroke="#3b82f6" stroke-width="1" opacity="0.3" />
|
||||||
|
<path :d="getVotePath(vote, 2)" fill="none" stroke="#ef4444" stroke-width="1" opacity="0.3" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Median curve (if available) -->
|
||||||
|
<g v-if="medianVote">
|
||||||
|
<path :d="getVotePath(medianVote, 1)" fill="none" stroke="#1e40af" stroke-width="3" />
|
||||||
|
<path :d="getVotePath(medianVote, 2)" fill="none" stroke="#991b1b" stroke-width="3" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Axis labels -->
|
||||||
|
<text :x="svgW / 2" :y="svgH - 2" text-anchor="middle" font-size="11" fill="#64748b">
|
||||||
|
Volume (m³)
|
||||||
|
</text>
|
||||||
|
<text :x="6" :y="12" font-size="11" fill="#64748b">€/m³</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { paramsToControlPoints } from '~/utils/bezier-math'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
votes: Array<{ vinf: number; a: number; b: number; c: number; d: number; e: number; computed_p0?: number }>
|
||||||
|
slug: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const svgW = 600
|
||||||
|
const svgH = 300
|
||||||
|
const margin = { top: 15, right: 15, bottom: 25, left: 30 }
|
||||||
|
const plotW = svgW - margin.left - margin.right
|
||||||
|
const plotH = svgH - margin.top - margin.bottom
|
||||||
|
|
||||||
|
const vmax = ref(2100)
|
||||||
|
const pmax = ref(20)
|
||||||
|
const medianVote = ref<any>(null)
|
||||||
|
|
||||||
|
function toSvgX(v: number): number {
|
||||||
|
return margin.left + (v / vmax.value) * plotW
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSvgY(p: number): number {
|
||||||
|
return margin.top + plotH - (p / pmax.value) * plotH
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridVolumes = computed(() => {
|
||||||
|
const step = Math.ceil(vmax.value / 7 / 100) * 100
|
||||||
|
const arr = []
|
||||||
|
for (let v = step; v < vmax.value; v += step) arr.push(v)
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
|
||||||
|
const gridPrices = computed(() => {
|
||||||
|
const step = Math.ceil(pmax.value / 5)
|
||||||
|
const arr = []
|
||||||
|
for (let p = step; p < pmax.value; p += step) arr.push(p)
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
|
||||||
|
function getVotePath(vote: any, tier: number): string {
|
||||||
|
const p0 = vote.computed_p0 || 5
|
||||||
|
const cp = paramsToControlPoints(vote.vinf, vmax.value, pmax.value, p0, vote.a, vote.b, vote.c, vote.d, vote.e)
|
||||||
|
|
||||||
|
if (tier === 1) {
|
||||||
|
return `M ${toSvgX(cp.p1.x)} ${toSvgY(cp.p1.y)} C ${toSvgX(cp.p2.x)} ${toSvgY(cp.p2.y)}, ${toSvgX(cp.p3.x)} ${toSvgY(cp.p3.y)}, ${toSvgX(cp.p4.x)} ${toSvgY(cp.p4.y)}`
|
||||||
|
} else {
|
||||||
|
return `M ${toSvgX(cp.p4.x)} ${toSvgY(cp.p4.y)} C ${toSvgX(cp.p5.x)} ${toSvgY(cp.p5.y)}, ${toSvgX(cp.p6.x)} ${toSvgY(cp.p6.y)}, ${toSvgX(cp.p7.x)} ${toSvgY(cp.p7.y)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const params = await api.get<any>(`/communes/${props.slug}/params`)
|
||||||
|
vmax.value = params.vmax
|
||||||
|
pmax.value = params.pmax
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
medianVote.value = await api.get(`/communes/${props.slug}/votes/median`)
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overlay-chart svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
frontend/app/composables/useApi.ts
Normal file
67
frontend/app/composables/useApi.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Composable for API calls to the FastAPI backend.
|
||||||
|
*/
|
||||||
|
export function useApi() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const baseURL = config.public.apiBase as string
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
if (import.meta.client) {
|
||||||
|
return localStorage.getItem('sejeteralo_token')
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(options.headers as Record<string, string> || {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getToken()
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.body && !(options.body instanceof FormData)) {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(`${baseURL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Impossible de contacter le serveur (${baseURL}). Vérifiez que le backend est lancé.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: response.statusText }))
|
||||||
|
throw new Error(error.detail || `Erreur API ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
if (!text) return {} as T
|
||||||
|
return JSON.parse(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: <T>(path: string) => apiFetch<T>(path),
|
||||||
|
post: <T>(path: string, body?: unknown) =>
|
||||||
|
apiFetch<T>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: body instanceof FormData ? body : JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
put: <T>(path: string, body?: unknown) =>
|
||||||
|
apiFetch<T>(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
delete: <T>(path: string) =>
|
||||||
|
apiFetch<T>(path, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
|
}
|
||||||
96
frontend/app/layouts/default.vue
Normal file
96
frontend/app/layouts/default.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-layout">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="container header-inner">
|
||||||
|
<NuxtLink to="/" class="logo">SejeteralO</NuxtLink>
|
||||||
|
<nav class="header-nav">
|
||||||
|
<NuxtLink to="/">Accueil</NuxtLink>
|
||||||
|
<template v-if="authStore.isAuthenticated">
|
||||||
|
<NuxtLink v-if="authStore.isSuperAdmin" to="/admin">Super Admin</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
v-else-if="authStore.isAdmin && authStore.communeSlug"
|
||||||
|
:to="`/admin/communes/${authStore.communeSlug}`"
|
||||||
|
>
|
||||||
|
Gestion commune
|
||||||
|
</NuxtLink>
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="logout">Déconnexion</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="app-main container">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="container">
|
||||||
|
SejeteralO — Outil de démocratie participative pour la tarification de l'eau
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
authStore.logout()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding-top: 2rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding: 1rem 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
frontend/app/middleware/admin.ts
Normal file
11
frontend/app/middleware/admin.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Route middleware: redirects to login if user is not an admin.
|
||||||
|
* Apply via definePageMeta({ middleware: 'admin' }) on admin pages.
|
||||||
|
*/
|
||||||
|
export default defineNuxtRouteMiddleware((to) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (!authStore.isAuthenticated || !authStore.isAdmin) {
|
||||||
|
return navigateTo('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
317
frontend/app/pages/admin/communes/[slug]/content.vue
Normal file
317
frontend/app/pages/admin/communes/[slug]/content.vue
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">← {{ slug }}</NuxtLink>
|
||||||
|
<h1>Contenu CMS</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error">{{ error }}</div>
|
||||||
|
<div v-if="success" class="alert alert-success">{{ success }}</div>
|
||||||
|
|
||||||
|
<!-- Create new page -->
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h3>Pages de contenu</h3>
|
||||||
|
<button class="btn btn-primary" @click="showCreate = !showCreate">
|
||||||
|
{{ showCreate ? 'Annuler' : 'Nouvelle page' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showCreate" style="margin-bottom: 1rem; padding: 1rem; background: var(--color-bg); border-radius: var(--radius);">
|
||||||
|
<div class="grid grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Slug (identifiant URL)</label>
|
||||||
|
<input v-model="newSlug" class="form-input" placeholder="ex: presentation" pattern="[a-z0-9-]+" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Titre</label>
|
||||||
|
<input v-model="newTitle" class="form-input" placeholder="ex: Presentation de la commune" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @click="createPage" :disabled="!newSlug || !newTitle">
|
||||||
|
Creer la page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pages list -->
|
||||||
|
<div v-if="loading" style="text-align: center; padding: 1rem;">
|
||||||
|
<div class="spinner" style="margin: 0 auto;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="pages.length === 0" class="alert alert-info">
|
||||||
|
Aucune page de contenu. Cliquez sur "Nouvelle page" pour en creer une.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-for="page in pages" :key="page.slug"
|
||||||
|
class="page-item"
|
||||||
|
:class="{ active: editing?.slug === page.slug }"
|
||||||
|
@click="startEdit(page)"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>{{ page.title }}</strong>
|
||||||
|
<span style="color: var(--color-text-muted); font-size: 0.8rem; margin-left: 0.5rem;">
|
||||||
|
/{{ page.slug }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--color-text-muted);">
|
||||||
|
{{ new Date(page.updated_at).toLocaleDateString('fr-FR') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor -->
|
||||||
|
<div v-if="editing" class="card">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h3>{{ editing.title }} <span style="color: var(--color-text-muted); font-size: 0.8rem;">/{{ editing.slug }}</span></h3>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button class="btn btn-secondary" @click="previewMode = !previewMode">
|
||||||
|
{{ previewMode ? 'Editer' : 'Apercu' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger btn-sm" @click="confirmDelete">Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Titre</label>
|
||||||
|
<input v-model="editing.title" class="form-input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!previewMode" class="form-group">
|
||||||
|
<label>Contenu (Markdown)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="editing.body_markdown"
|
||||||
|
class="form-input content-textarea"
|
||||||
|
rows="15"
|
||||||
|
placeholder="Redigez votre contenu en Markdown..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="preview-box">
|
||||||
|
<div v-html="renderMarkdown(editing.body_markdown)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
|
||||||
|
<button class="btn btn-primary" @click="savePage" :disabled="saving">
|
||||||
|
{{ saving ? 'Enregistrement...' : 'Enregistrer' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="editing = null">Fermer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
<div v-if="deleting" class="modal-overlay" @click.self="deleting = false">
|
||||||
|
<div class="card modal-content">
|
||||||
|
<h3>Supprimer cette page ?</h3>
|
||||||
|
<p style="margin: 1rem 0; color: var(--color-text-muted);">
|
||||||
|
Supprimer la page <strong>{{ editing?.title }}</strong> est irreversible.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
|
||||||
|
<button class="btn btn-secondary" @click="deleting = false">Annuler</button>
|
||||||
|
<button class="btn btn-danger" @click="doDelete">Confirmer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: 'admin' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const api = useApi()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
|
||||||
|
interface ContentPage {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
body_markdown: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = ref<ContentPage[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const newSlug = ref('')
|
||||||
|
const newTitle = ref('')
|
||||||
|
const editing = ref<ContentPage | null>(null)
|
||||||
|
const previewMode = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadPages()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadPages() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
pages.value = await api.get<ContentPage[]>(`/communes/${slug}/content`)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPage() {
|
||||||
|
error.value = ''; success.value = ''
|
||||||
|
try {
|
||||||
|
const page = await api.put<ContentPage>(
|
||||||
|
`/communes/${slug}/content/${newSlug.value}`,
|
||||||
|
{ title: newTitle.value, body_markdown: '' },
|
||||||
|
)
|
||||||
|
showCreate.value = false
|
||||||
|
newSlug.value = ''; newTitle.value = ''
|
||||||
|
await loadPages()
|
||||||
|
startEdit(page)
|
||||||
|
success.value = 'Page creee'
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(page: ContentPage) {
|
||||||
|
editing.value = { ...page }
|
||||||
|
previewMode.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePage() {
|
||||||
|
if (!editing.value) return
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''; success.value = ''
|
||||||
|
try {
|
||||||
|
await api.put(
|
||||||
|
`/communes/${slug}/content/${editing.value.slug}`,
|
||||||
|
{ title: editing.value.title, body_markdown: editing.value.body_markdown },
|
||||||
|
)
|
||||||
|
success.value = 'Page enregistree'
|
||||||
|
await loadPages()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
deleting.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!editing.value) return
|
||||||
|
error.value = ''; success.value = ''
|
||||||
|
try {
|
||||||
|
await api.delete(`/communes/${slug}/content/${editing.value.slug}`)
|
||||||
|
success.value = 'Page supprimee'
|
||||||
|
editing.value = null
|
||||||
|
deleting.value = false
|
||||||
|
await loadPages()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(md: string): string {
|
||||||
|
if (!md) return '<p style="color: var(--color-text-muted);">Aucun contenu.</p>'
|
||||||
|
// Simple markdown rendering (headings, bold, italic, links, paragraphs, lists)
|
||||||
|
return md
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||||
|
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||||
|
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
|
||||||
|
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||||
|
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||||
|
.replace(/\n\n/g, '</p><p>')
|
||||||
|
.replace(/^(?!<[hulo])(.+)$/gm, '<p>$1</p>')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item.active {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-textarea {
|
||||||
|
font-family: 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-box {
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
min-height: 200px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-box h1 { font-size: 1.5rem; margin: 1rem 0 0.5rem; }
|
||||||
|
.preview-box h2 { font-size: 1.25rem; margin: 0.75rem 0 0.5rem; }
|
||||||
|
.preview-box h3 { font-size: 1.1rem; margin: 0.5rem 0 0.25rem; }
|
||||||
|
.preview-box p { margin: 0.5rem 0; }
|
||||||
|
.preview-box ul { margin: 0.5rem 0; padding-left: 1.5rem; }
|
||||||
|
.preview-box a { color: var(--color-primary); }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
max-width: 480px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
111
frontend/app/pages/admin/communes/[slug]/import.vue
Normal file
111
frontend/app/pages/admin/communes/[slug]/import.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">← {{ slug }}</NuxtLink>
|
||||||
|
<h1>Import des foyers</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width: 700px;">
|
||||||
|
<p style="margin-bottom: 1rem;">
|
||||||
|
Importez un fichier CSV ou XLSX avec les colonnes :
|
||||||
|
<code>identifier, status, volume_m3, price_eur</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a :href="`${apiBase}/communes/${slug}/households/template`" class="btn btn-secondary" style="margin-bottom: 1rem;">
|
||||||
|
Télécharger le template
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Fichier (CSV ou XLSX)</label>
|
||||||
|
<input type="file" accept=".csv,.xlsx,.xls" @change="onFileChange" class="form-input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div v-if="preview" style="margin: 1rem 0;">
|
||||||
|
<div v-if="preview.errors.length" class="alert alert-error">
|
||||||
|
<strong>Erreurs :</strong>
|
||||||
|
<ul style="margin: 0.5rem 0 0 1rem;">
|
||||||
|
<li v-for="err in preview.errors" :key="err">{{ err }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-else class="alert alert-success">
|
||||||
|
{{ preview.valid_rows }} foyers valides prêts à importer.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<div v-if="result" class="alert alert-success">
|
||||||
|
{{ result.created }} foyers importés.
|
||||||
|
<span v-if="result.errors.length"> ({{ result.errors.length }} avertissements)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
:disabled="!file || previewLoading"
|
||||||
|
@click="doPreview"
|
||||||
|
>
|
||||||
|
Vérifier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="!file || importLoading || (preview && preview.errors.length > 0)"
|
||||||
|
@click="doImport"
|
||||||
|
>
|
||||||
|
Importer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: 'admin' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const api = useApi()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
const apiBase = config.public.apiBase as string
|
||||||
|
|
||||||
|
const file = ref<File | null>(null)
|
||||||
|
const preview = ref<any>(null)
|
||||||
|
const result = ref<any>(null)
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
const importLoading = ref(false)
|
||||||
|
|
||||||
|
function onFileChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
file.value = input.files?.[0] || null
|
||||||
|
preview.value = null
|
||||||
|
result.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doPreview() {
|
||||||
|
if (!file.value) return
|
||||||
|
previewLoading.value = true
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file.value)
|
||||||
|
try {
|
||||||
|
preview.value = await api.post(`/communes/${slug}/households/import/preview`, fd)
|
||||||
|
} catch (e: any) {
|
||||||
|
preview.value = { valid_rows: 0, errors: [e.message], sample: [] }
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doImport() {
|
||||||
|
if (!file.value) return
|
||||||
|
importLoading.value = true
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file.value)
|
||||||
|
try {
|
||||||
|
result.value = await api.post(`/communes/${slug}/households/import`, fd)
|
||||||
|
} catch (e: any) {
|
||||||
|
result.value = { created: 0, errors: [e.message] }
|
||||||
|
} finally {
|
||||||
|
importLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
242
frontend/app/pages/admin/communes/[slug]/index.vue
Normal file
242
frontend/app/pages/admin/communes/[slug]/index.vue
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="commune">
|
||||||
|
<div class="page-header">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
|
<NuxtLink v-if="authStore.isSuperAdmin" to="/admin" style="color: var(--color-text-muted);">← Admin</NuxtLink>
|
||||||
|
<h1>{{ commune.name }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-2" style="margin-bottom: 2rem;">
|
||||||
|
<NuxtLink :to="`/admin/communes/${slug}/params`" class="card nav-card">
|
||||||
|
<h3>Parametres tarifs</h3>
|
||||||
|
<p class="nav-card-desc">Configurer les recettes, abonnements, prix max...</p>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink :to="`/admin/communes/${slug}/import`" class="card nav-card">
|
||||||
|
<h3>Import foyers</h3>
|
||||||
|
<p class="nav-card-desc">Importer les donnees des foyers (CSV/XLSX)</p>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink :to="`/admin/communes/${slug}/votes`" class="card nav-card">
|
||||||
|
<h3>Votes</h3>
|
||||||
|
<p class="nav-card-desc">Consulter les votes, la mediane et l'overlay</p>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink :to="`/admin/communes/${slug}/content`" class="card nav-card">
|
||||||
|
<h3>Contenu CMS</h3>
|
||||||
|
<p class="nav-card-desc">Editer le contenu de la page commune</p>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div v-if="stats" class="card" style="margin-bottom: 2rem;">
|
||||||
|
<h3 style="margin-bottom: 1rem;">Statistiques foyers</h3>
|
||||||
|
<div class="grid grid-4">
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 2rem; font-weight: 700;">{{ stats.total }}</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Foyers total</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 2rem; font-weight: 700;">{{ stats.voted_count }}</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Ont vote</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 2rem; font-weight: 700;">{{ stats.avg_volume?.toFixed(1) }}</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Volume moyen (m3)</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size: 2rem; font-weight: 700;">{{ stats.median_volume?.toFixed(1) }}</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--color-text-muted);">Volume median (m3)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Household codes management -->
|
||||||
|
<div class="card">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h3>Codes foyers</h3>
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Rechercher un foyer..."
|
||||||
|
style="width: 220px; padding: 0.375rem 0.75rem; font-size: 0.875rem;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="householdsLoading" style="text-align: center; padding: 1rem;">
|
||||||
|
<div class="spinner" style="margin: 0 auto;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="households.length === 0" class="alert alert-info">
|
||||||
|
Aucun foyer importe. Utilisez la page "Import foyers" pour charger les donnees.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">
|
||||||
|
{{ filteredHouseholds.length }} foyer(s) affiche(s) sur {{ households.length }}
|
||||||
|
</p>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Identifiant</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th>Volume (m3)</th>
|
||||||
|
<th>Code d'acces</th>
|
||||||
|
<th>A vote</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="h in paginatedHouseholds" :key="h.id">
|
||||||
|
<td>{{ h.identifier }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="statusBadge(h.status)">{{ h.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ h.volume_m3.toFixed(1) }}</td>
|
||||||
|
<td>
|
||||||
|
<code class="auth-code">{{ h.auth_code }}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="h.has_voted" style="color: #059669;">Oui</span>
|
||||||
|
<span v-else style="color: var(--color-text-muted);">Non</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="totalPages > 1" style="display: flex; justify-content: center; gap: 0.5rem; margin-top: 1rem;">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
:disabled="page === 1"
|
||||||
|
@click="page--"
|
||||||
|
>« Prec.</button>
|
||||||
|
<span style="padding: 0.375rem 0.5rem; font-size: 0.875rem;">
|
||||||
|
{{ page }} / {{ totalPages }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
:disabled="page >= totalPages"
|
||||||
|
@click="page++"
|
||||||
|
>Suiv. »</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: 'admin' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
const commune = ref<any>(null)
|
||||||
|
const stats = ref<any>(null)
|
||||||
|
const households = ref<any[]>([])
|
||||||
|
const householdsLoading = ref(false)
|
||||||
|
const search = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = 20
|
||||||
|
|
||||||
|
const filteredHouseholds = computed(() => {
|
||||||
|
if (!search.value) return households.value
|
||||||
|
const q = search.value.toLowerCase()
|
||||||
|
return households.value.filter(h =>
|
||||||
|
h.identifier.toLowerCase().includes(q) ||
|
||||||
|
h.auth_code.toLowerCase().includes(q) ||
|
||||||
|
h.status.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(filteredHouseholds.value.length / perPage)))
|
||||||
|
|
||||||
|
const paginatedHouseholds = computed(() => {
|
||||||
|
const start = (page.value - 1) * perPage
|
||||||
|
return filteredHouseholds.value.slice(start, start + perPage)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(search, () => { page.value = 1 })
|
||||||
|
|
||||||
|
function statusBadge(status: string) {
|
||||||
|
if (status === 'RS') return 'badge-amber'
|
||||||
|
if (status === 'PRO') return 'badge-blue'
|
||||||
|
return 'badge-green'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
commune.value = await api.get<any>(`/communes/${slug}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load stats and households in parallel
|
||||||
|
householdsLoading.value = true
|
||||||
|
try {
|
||||||
|
const [s, hh] = await Promise.all([
|
||||||
|
api.get<any>(`/communes/${slug}/households/stats`),
|
||||||
|
api.get<any[]>(`/communes/${slug}/households`),
|
||||||
|
])
|
||||||
|
stats.value = s
|
||||||
|
households.value = hh
|
||||||
|
} catch (e: any) {
|
||||||
|
// stats or households may fail if not imported yet
|
||||||
|
} finally {
|
||||||
|
householdsLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-card h3 {
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-card-desc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-code {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-blue {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
77
frontend/app/pages/admin/communes/[slug]/params.vue
Normal file
77
frontend/app/pages/admin/communes/[slug]/params.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">← {{ slug }}</NuxtLink>
|
||||||
|
<h1>Paramètres tarifs</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="saved" class="alert alert-success">Paramètres enregistrés.</div>
|
||||||
|
<div v-if="error" class="alert alert-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width: 600px;">
|
||||||
|
<form @submit.prevent="save">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Recettes cibles (€)</label>
|
||||||
|
<input v-model.number="form.recettes" type="number" class="form-input" step="1000" min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Abonnement RP/PRO (€)</label>
|
||||||
|
<input v-model.number="form.abop" type="number" class="form-input" step="1" min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Abonnement RS (€)</label>
|
||||||
|
<input v-model.number="form.abos" type="number" class="form-input" step="1" min="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Prix max/m³ (€)</label>
|
||||||
|
<input v-model.number="form.pmax" type="number" class="form-input" step="0.5" min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Volume max (m³)</label>
|
||||||
|
<input v-model.number="form.vmax" type="number" class="form-input" step="100" min="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: 'admin' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const api = useApi()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
|
||||||
|
const form = reactive({ recettes: 75000, abop: 100, abos: 100, pmax: 20, vmax: 2100 })
|
||||||
|
const loading = ref(false)
|
||||||
|
const saved = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const params = await api.get<typeof form>(`/communes/${slug}/params`)
|
||||||
|
Object.assign(form, params)
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
loading.value = true
|
||||||
|
saved.value = false
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await api.put(`/communes/${slug}/params`, form)
|
||||||
|
saved.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
89
frontend/app/pages/admin/communes/[slug]/votes.vue
Normal file
89
frontend/app/pages/admin/communes/[slug]/votes.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<NuxtLink :to="`/admin/communes/${slug}`" style="color: var(--color-text-muted);">← {{ slug }}</NuxtLink>
|
||||||
|
<h1>Votes</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Median -->
|
||||||
|
<div v-if="median" class="card" style="margin-bottom: 1.5rem;">
|
||||||
|
<h3>Médiane ({{ median.vote_count }} votes)</h3>
|
||||||
|
<div class="grid grid-4" style="margin-top: 1rem;">
|
||||||
|
<div><strong>vinf:</strong> {{ median.vinf.toFixed(0) }}</div>
|
||||||
|
<div><strong>a:</strong> {{ median.a.toFixed(3) }}</div>
|
||||||
|
<div><strong>b:</strong> {{ median.b.toFixed(3) }}</div>
|
||||||
|
<div><strong>c:</strong> {{ median.c.toFixed(3) }}</div>
|
||||||
|
<div><strong>d:</strong> {{ median.d.toFixed(3) }}</div>
|
||||||
|
<div><strong>e:</strong> {{ median.e.toFixed(3) }}</div>
|
||||||
|
<div><strong>p0:</strong> {{ median.computed_p0.toFixed(2) }} €/m³</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vote overlay chart placeholder -->
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem;">
|
||||||
|
<h3>Overlay des courbes</h3>
|
||||||
|
<VoteOverlayChart v-if="overlayData.length" :votes="overlayData" :slug="slug" />
|
||||||
|
<p v-else style="color: var(--color-text-muted); padding: 2rem; text-align: center;">
|
||||||
|
Aucun vote pour le moment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vote list -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="margin-bottom: 1rem;">Liste des votes actifs</h3>
|
||||||
|
<table class="table" v-if="votes.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Foyer</th>
|
||||||
|
<th>vinf</th>
|
||||||
|
<th>a</th>
|
||||||
|
<th>b</th>
|
||||||
|
<th>c</th>
|
||||||
|
<th>d</th>
|
||||||
|
<th>e</th>
|
||||||
|
<th>p0</th>
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="v in votes" :key="v.id">
|
||||||
|
<td>#{{ v.household_id }}</td>
|
||||||
|
<td>{{ v.vinf.toFixed(0) }}</td>
|
||||||
|
<td>{{ v.a.toFixed(2) }}</td>
|
||||||
|
<td>{{ v.b.toFixed(2) }}</td>
|
||||||
|
<td>{{ v.c.toFixed(2) }}</td>
|
||||||
|
<td>{{ v.d.toFixed(2) }}</td>
|
||||||
|
<td>{{ v.e.toFixed(2) }}</td>
|
||||||
|
<td>{{ v.computed_p0?.toFixed(2) }}</td>
|
||||||
|
<td>{{ new Date(v.submitted_at).toLocaleDateString() }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else style="color: var(--color-text-muted);">Aucun vote actif.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: 'admin' })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const api = useApi()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
|
||||||
|
const votes = ref<any[]>([])
|
||||||
|
const median = ref<any>(null)
|
||||||
|
const overlayData = ref<any[]>([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
[votes.value, overlayData.value] = await Promise.all([
|
||||||
|
api.get<any[]>(`/communes/${slug}/votes`),
|
||||||
|
api.get<any[]>(`/communes/${slug}/votes/overlay`),
|
||||||
|
])
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
median.value = await api.get(`/communes/${slug}/votes/median`)
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
225
frontend/app/pages/admin/index.vue
Normal file
225
frontend/app/pages/admin/index.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Redirect commune admin to their commune page -->
|
||||||
|
<div v-if="!authStore.isSuperAdmin && authStore.communeSlug">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Redirection vers votre espace commune...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Super admin authenticated -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="page-header"><h1>Super administration</h1></div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||||
|
<h2>Communes</h2>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button class="btn btn-secondary" @click="showAdminCreate = !showAdminCreate">
|
||||||
|
{{ showAdminCreate ? 'Masquer' : 'Nouvel admin commune' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" @click="showCreate = true">
|
||||||
|
Nouvelle commune
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error">{{ error }}</div>
|
||||||
|
<div v-if="success" class="alert alert-success">{{ success }}</div>
|
||||||
|
|
||||||
|
<!-- Create commune admin form -->
|
||||||
|
<div v-if="showAdminCreate" class="card" style="margin-bottom: 1.5rem;">
|
||||||
|
<h3 style="margin-bottom: 1rem;">Creer un admin commune</h3>
|
||||||
|
<form @submit.prevent="createAdmin">
|
||||||
|
<div class="grid grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input v-model="newAdmin.email" type="email" class="form-input" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Mot de passe</label>
|
||||||
|
<input v-model="newAdmin.password" type="password" class="form-input" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nom complet</label>
|
||||||
|
<input v-model="newAdmin.full_name" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Commune</label>
|
||||||
|
<select v-model="newAdmin.commune_slug" class="form-input">
|
||||||
|
<option value="">-- Aucune --</option>
|
||||||
|
<option v-for="c in communes" :key="c.id" :value="c.slug">{{ c.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">Creer l'admin</button>
|
||||||
|
<button type="button" class="btn btn-secondary" @click="showAdminCreate = false">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create commune form -->
|
||||||
|
<div v-if="showCreate" class="card" style="margin-bottom: 1.5rem;">
|
||||||
|
<h3 style="margin-bottom: 1rem;">Creer une commune</h3>
|
||||||
|
<form @submit.prevent="createCommune">
|
||||||
|
<div class="grid grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nom</label>
|
||||||
|
<input v-model="newCommune.name" class="form-input" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Slug (URL)</label>
|
||||||
|
<input v-model="newCommune.slug" class="form-input" required pattern="[a-z0-9-]+" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<input v-model="newCommune.description" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">Creer</button>
|
||||||
|
<button type="button" class="btn btn-secondary" @click="showCreate = false">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" style="text-align: center; padding: 2rem;">
|
||||||
|
<div class="spinner" style="margin: 0 auto;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-3">
|
||||||
|
<div v-for="commune in communes" :key="commune.id" class="card commune-card">
|
||||||
|
<NuxtLink :to="`/admin/communes/${commune.slug}`" style="text-decoration: none; color: inherit;">
|
||||||
|
<h3>{{ commune.name }}</h3>
|
||||||
|
<p style="font-size: 0.875rem; color: var(--color-text-muted);">{{ commune.description }}</p>
|
||||||
|
<span class="badge" :class="commune.is_active ? 'badge-green' : 'badge-amber'" style="margin-top: 0.5rem;">
|
||||||
|
{{ commune.is_active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger btn-sm"
|
||||||
|
style="margin-top: 0.75rem;"
|
||||||
|
@click.prevent="confirmDelete(commune)"
|
||||||
|
>Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
<div v-if="deletingCommune" class="modal-overlay" @click.self="deletingCommune = null">
|
||||||
|
<div class="card modal-content">
|
||||||
|
<h3>Supprimer la commune ?</h3>
|
||||||
|
<p style="margin: 1rem 0; color: var(--color-text-muted);">
|
||||||
|
Supprimer <strong>{{ deletingCommune.name }}</strong> effacera toutes les donnees associees
|
||||||
|
(foyers, votes, parametres). Cette action est irreversible.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
|
||||||
|
<button class="btn btn-secondary" @click="deletingCommune = null">Annuler</button>
|
||||||
|
<button class="btn btn-danger" @click="doDelete">Confirmer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({ middleware: 'admin' })
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const communes = ref<any[]>([])
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const showAdminCreate = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
const newCommune = reactive({ name: '', slug: '', description: '' })
|
||||||
|
const newAdmin = reactive({ email: '', password: '', full_name: '', commune_slug: '' })
|
||||||
|
const deletingCommune = ref<any>(null)
|
||||||
|
|
||||||
|
// Redirect commune admin away from super admin page
|
||||||
|
onMounted(async () => {
|
||||||
|
if (authStore.isAdmin && !authStore.isSuperAdmin && authStore.communeSlug) {
|
||||||
|
router.replace(`/admin/communes/${authStore.communeSlug}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (authStore.isSuperAdmin) {
|
||||||
|
await loadCommunes()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadCommunes() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
communes.value = await api.get<any[]>('/communes/')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCommune() {
|
||||||
|
error.value = ''; success.value = ''
|
||||||
|
try {
|
||||||
|
await api.post('/communes/', newCommune)
|
||||||
|
showCreate.value = false
|
||||||
|
newCommune.name = ''; newCommune.slug = ''; newCommune.description = ''
|
||||||
|
await loadCommunes()
|
||||||
|
success.value = 'Commune creee'
|
||||||
|
} catch (e: any) { error.value = e.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAdmin() {
|
||||||
|
error.value = ''; success.value = ''
|
||||||
|
try {
|
||||||
|
await api.post('/auth/admin/create', {
|
||||||
|
email: newAdmin.email, password: newAdmin.password,
|
||||||
|
full_name: newAdmin.full_name, role: 'commune_admin',
|
||||||
|
commune_slugs: newAdmin.commune_slug ? [newAdmin.commune_slug] : [],
|
||||||
|
})
|
||||||
|
showAdminCreate.value = false
|
||||||
|
newAdmin.email = ''; newAdmin.password = ''; newAdmin.full_name = ''; newAdmin.commune_slug = ''
|
||||||
|
success.value = 'Admin commune cree'
|
||||||
|
} catch (e: any) { error.value = e.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(c: any) { deletingCommune.value = c }
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!deletingCommune.value) return
|
||||||
|
error.value = ''; success.value = ''
|
||||||
|
try {
|
||||||
|
await api.delete(`/communes/${deletingCommune.value.slug}`)
|
||||||
|
success.value = `Commune "${deletingCommune.value.name}" supprimee`
|
||||||
|
deletingCommune.value = null
|
||||||
|
await loadCommunes()
|
||||||
|
} catch (e: any) { error.value = e.message }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.commune-card { transition: box-shadow 0.15s; }
|
||||||
|
.commune-card:hover { box-shadow: var(--shadow-md); }
|
||||||
|
.commune-card h3 { color: var(--color-primary); margin-bottom: 0.25rem; }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc2626; color: white; border: none;
|
||||||
|
padding: 0.375rem 0.75rem; border-radius: 0.375rem; cursor: pointer; font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: #b91c1c; }
|
||||||
|
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||||
|
.alert-success {
|
||||||
|
background: #dcfce7; color: #166534;
|
||||||
|
padding: 0.75rem 1rem; border-radius: 0.5rem; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||||
|
}
|
||||||
|
.modal-content { max-width: 480px; width: 90%; }
|
||||||
|
</style>
|
||||||
11
frontend/app/pages/commune/[slug]/citizen.vue
Normal file
11
frontend/app/pages/commune/[slug]/citizen.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Redirect to main commune page (editor is now integrated there)
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
onMounted(() => router.replace(`/commune/${slug}`))
|
||||||
|
</script>
|
||||||
724
frontend/app/pages/commune/[slug]/index.vue
Normal file
724
frontend/app/pages/commune/[slug]/index.vue
Normal file
@@ -0,0 +1,724 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="commune">
|
||||||
|
<div class="page-header">
|
||||||
|
<NuxtLink to="/" style="color: var(--color-text-muted); font-size: 0.875rem;">← Toutes les communes</NuxtLink>
|
||||||
|
<h1>{{ commune.name }}</h1>
|
||||||
|
<p style="color: var(--color-text-muted);">{{ commune.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="card" style="text-align: center; padding: 3rem;">
|
||||||
|
<div class="spinner" style="margin: 0 auto;"></div>
|
||||||
|
<p style="margin-top: 1rem; color: var(--color-text-muted);">Chargement...</p>
|
||||||
|
</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
|
||||||
|
(= dernier graph de eau.py — NewModel bottom subplot)
|
||||||
|
═══════════════════════════════════════════════════════ -->
|
||||||
|
<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;">
|
||||||
|
<h3>Tarification progressive — Prix au m<sup>3</sup></h3>
|
||||||
|
<span v-if="curveData.has_votes" class="badge badge-green">
|
||||||
|
Mediane de {{ curveData.vote_count }} vote(s)
|
||||||
|
</span>
|
||||||
|
<span v-else class="badge badge-amber">Courbe par defaut</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 1rem;">
|
||||||
|
Deplacez les poignees pour ajuster la forme de la courbe.
|
||||||
|
Le prix d'inflexion p<sub>0</sub> s'ajuste automatiquement pour equilibrer les recettes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<!-- Grid -->
|
||||||
|
<g>
|
||||||
|
<line v-for="v in gridVols" :key="'gv'+v"
|
||||||
|
:x1="cx(v)" :y1="cy(0)" :x2="cx(v)" :y2="cy(pmax)"
|
||||||
|
stroke="#e2e8f0" stroke-width="0.5" />
|
||||||
|
<line v-for="p in gridPrices" :key="'gp'+p"
|
||||||
|
:x1="cx(0)" :y1="cy(p)" :x2="cx(vmax)" :y2="cy(p)"
|
||||||
|
stroke="#e2e8f0" stroke-width="0.5" />
|
||||||
|
<!-- Volume labels -->
|
||||||
|
<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>
|
||||||
|
<!-- Price labels -->
|
||||||
|
<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 }}
|
||||||
|
</text>
|
||||||
|
<!-- Axes labels -->
|
||||||
|
<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">
|
||||||
|
Prix/m3
|
||||||
|
</text>
|
||||||
|
</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) -->
|
||||||
|
<path :d="tier1Path" fill="none" stroke="#2563eb" stroke-width="2.5" />
|
||||||
|
<!-- Bezier curve: tier 2 (red) -->
|
||||||
|
<path :d="tier2Path" fill="none" stroke="#dc2626" stroke-width="2.5" />
|
||||||
|
|
||||||
|
<!-- Inflection reference lines -->
|
||||||
|
<line :x1="cx(bp.vinf)" :y1="cy(0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
|
||||||
|
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
||||||
|
<line :x1="cx(0)" :y1="cy(localP0)" :x2="cx(bp.vinf)" :y2="cy(localP0)"
|
||||||
|
stroke="#94a3b8" stroke-width="1" stroke-dasharray="4" />
|
||||||
|
|
||||||
|
<!-- p0 label -->
|
||||||
|
<text :x="cx(0) + 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)"
|
||||||
|
/>
|
||||||
|
<!-- Point labels -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════
|
||||||
|
GRAPH 2: Static baseline — Modele lineaire actuel
|
||||||
|
(= 1er graph de eau.py — CurrentModel)
|
||||||
|
═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem;">
|
||||||
|
<h3 style="margin-bottom: 0.5rem;">Tarification actuelle (modele lineaire)</h3>
|
||||||
|
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 1rem;">
|
||||||
|
Situation tarifaire en vigueur : prix fixe de {{ curveData.p0_linear?.toFixed(2) }} EUR/m3 + abonnement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="baseline-charts">
|
||||||
|
<!-- Left: Facture totale -->
|
||||||
|
<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)"
|
||||||
|
stroke="#e2e8f0" stroke-width="0.5" />
|
||||||
|
<line v-for="b in gridBills" :key="'bg1b'+b"
|
||||||
|
:x1="cx2(0)" :y1="cy2bill(b)" :x2="cx2(vmax)" :y2="cy2bill(b)"
|
||||||
|
stroke="#e2e8f0" stroke-width="0.5" />
|
||||||
|
<text v-for="v in gridVols2" :key="'bg1lv'+v"
|
||||||
|
:x="cx2(v)" :y="H2 - 2" text-anchor="middle" font-size="9" fill="#94a3b8">{{ v }}</text>
|
||||||
|
<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>
|
||||||
|
<line x1="0" y1="12" x2="15" y2="12" stroke="#dc2626" stroke-width="1.5" />
|
||||||
|
<text x="18" y="15" font-size="9" fill="#1e293b">RS</text>
|
||||||
|
</g>
|
||||||
|
<text :x="W2/2" :y="H2" text-anchor="middle" font-size="9" fill="#64748b">volume (m3)</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Prix au m3 -->
|
||||||
|
<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)"
|
||||||
|
stroke="#e2e8f0" stroke-width="0.5" />
|
||||||
|
<line v-for="p in gridPrices2" :key="'bg2p'+p"
|
||||||
|
:x1="cx2(0)" :y1="cy2price(p)" :x2="cx2(vmax)" :y2="cy2price(p)"
|
||||||
|
stroke="#e2e8f0" stroke-width="0.5" />
|
||||||
|
<text v-for="v in gridVols2" :key="'bg2lv'+v"
|
||||||
|
:x="cx2(v)" :y="H2 - 2" text-anchor="middle" font-size="9" fill="#94a3b8">{{ v }}</text>
|
||||||
|
<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"
|
||||||
|
font-size="10" fill="#475569">
|
||||||
|
p0 = {{ curveData.p0_linear?.toFixed(2) }}
|
||||||
|
</text>
|
||||||
|
<text :x="W2/2" :y="H2" text-anchor="middle" font-size="9" fill="#64748b">volume (m3)</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tariff params info -->
|
||||||
|
<div class="card">
|
||||||
|
<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>
|
||||||
|
<div><strong>{{ params.abop }} EUR</strong><br/><span class="info-label">Abo RP/PRO</span></div>
|
||||||
|
<div><strong>{{ params.abos }} EUR</strong><br/><span class="info-label">Abo RS</span></div>
|
||||||
|
<div><strong>{{ params.pmax }} EUR/m3</strong><br/><span class="info-label">Prix max</span></div>
|
||||||
|
<div><strong>{{ params.vmax }} m3</strong><br/><span class="info-label">Volume max</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loadError" class="alert alert-error">{{ loadError }}</div>
|
||||||
|
|
||||||
|
<div v-else style="text-align: center; padding: 3rem;">
|
||||||
|
<div class="spinner" style="margin: 0 auto;"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
computeP0, computeImpacts, generateCurve,
|
||||||
|
paramsToControlPoints,
|
||||||
|
type HouseholdData, type ImpactRow, type ControlPoints,
|
||||||
|
} from '~/utils/bezier-math'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
const commune = ref<any>(null)
|
||||||
|
const params = ref<any>(null)
|
||||||
|
const curveData = ref<any>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref('')
|
||||||
|
const contentPages = ref<any[]>([])
|
||||||
|
|
||||||
|
// Bezier params (citizen-adjustable)
|
||||||
|
const bp = reactive({ vinf: 1050, a: 0.5, b: 0.5, c: 0.5, d: 0.5, e: 0.5 })
|
||||||
|
const localP0 = ref(0)
|
||||||
|
const impacts = ref<any[]>([])
|
||||||
|
const households = ref<HouseholdData[]>([])
|
||||||
|
|
||||||
|
// Tariff fixed params
|
||||||
|
const vmax = ref(2100)
|
||||||
|
const pmax = ref(20)
|
||||||
|
const recettes = ref(75000)
|
||||||
|
const abop = ref(100)
|
||||||
|
const abos = ref(100)
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
const authCode = ref('')
|
||||||
|
const authError = ref('')
|
||||||
|
const authLoading = ref(false)
|
||||||
|
const isCitizenAuth = computed(() => authStore.isCitizen && authStore.communeSlug === slug)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const voteSuccess = ref(false)
|
||||||
|
|
||||||
|
// ── Chart 1: Interactive Bezier ──
|
||||||
|
const W = 620
|
||||||
|
const H = 380
|
||||||
|
const margin = { top: 20, right: 20, bottom: 28, left: 45 }
|
||||||
|
const plotW = W - margin.left - margin.right
|
||||||
|
const plotH = H - margin.top - margin.bottom
|
||||||
|
|
||||||
|
function cx(v: number) { return margin.left + (v / vmax.value) * plotW }
|
||||||
|
function cy(p: number) { return margin.top + plotH - (p / pmax.value) * plotH }
|
||||||
|
function fromX(sx: number) { return ((sx - margin.left) / plotW) * vmax.value }
|
||||||
|
function fromY(sy: number) { return ((margin.top + plotH - sy) / plotH) * pmax.value }
|
||||||
|
|
||||||
|
const gridVols = computed(() => {
|
||||||
|
const step = Math.ceil(vmax.value / 7 / 100) * 100
|
||||||
|
const arr: number[] = []
|
||||||
|
for (let v = step; v < vmax.value; v += step) arr.push(v)
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
const gridPrices = computed(() => {
|
||||||
|
const step = Math.ceil(pmax.value / 5)
|
||||||
|
const arr: number[] = []
|
||||||
|
for (let p = step; p <= pmax.value; p += step) arr.push(p)
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
|
||||||
|
// Control points
|
||||||
|
const cp = computed<ControlPoints>(() =>
|
||||||
|
paramsToControlPoints(bp.vinf, vmax.value, pmax.value, localP0.value, bp.a, bp.b, bp.c, bp.d, bp.e)
|
||||||
|
)
|
||||||
|
|
||||||
|
const dragPoints = computed(() => ({
|
||||||
|
p2: cp.value.p2,
|
||||||
|
p3: cp.value.p3,
|
||||||
|
p4: cp.value.p4,
|
||||||
|
p5: cp.value.p5,
|
||||||
|
p6: cp.value.p6,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ptColors: Record<string, string> = {
|
||||||
|
p2: '#3b82f6', p3: '#3b82f6', p4: '#8b5cf6', p5: '#ef4444', p6: '#ef4444',
|
||||||
|
}
|
||||||
|
const ptLabels: Record<string, string> = {
|
||||||
|
p2: 'a', p3: 'b', p4: 'vinf', p5: 'c', p6: 'd,e',
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier1Path = computed(() => {
|
||||||
|
const c = cp.value
|
||||||
|
return `M ${cx(c.p1.x)} ${cy(c.p1.y)} C ${cx(c.p2.x)} ${cy(c.p2.y)}, ${cx(c.p3.x)} ${cy(c.p3.y)}, ${cx(c.p4.x)} ${cy(c.p4.y)}`
|
||||||
|
})
|
||||||
|
const tier2Path = computed(() => {
|
||||||
|
const c = cp.value
|
||||||
|
return `M ${cx(c.p4.x)} ${cy(c.p4.y)} C ${cx(c.p5.x)} ${cy(c.p5.y)}, ${cx(c.p6.x)} ${cy(c.p6.y)}, ${cx(c.p7.x)} ${cy(c.p7.y)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Drag handling ──
|
||||||
|
const svgRef = ref<SVGSVGElement | null>(null)
|
||||||
|
const dragging = ref<string | null>(null)
|
||||||
|
|
||||||
|
function getSvgPt(event: MouseEvent | Touch) {
|
||||||
|
if (!svgRef.value) return { x: 0, y: 0 }
|
||||||
|
const rect = svgRef.value.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
x: (event.clientX - rect.left) * (W / rect.width),
|
||||||
|
y: (event.clientY - rect.top) * (H / rect.height),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function startDrag(key: string) { dragging.value = key }
|
||||||
|
function onMouseMove(e: MouseEvent) { if (dragging.value) handleDrag(getSvgPt(e)) }
|
||||||
|
function onTouchMove(e: TouchEvent) { if (dragging.value && e.touches[0]) handleDrag(getSvgPt(e.touches[0])) }
|
||||||
|
function onMouseUp() {
|
||||||
|
if (dragging.value) {
|
||||||
|
dragging.value = null
|
||||||
|
debouncedServerCompute()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrag(pt: { x: number; y: number }) {
|
||||||
|
const v = Math.max(0, Math.min(vmax.value, fromX(pt.x)))
|
||||||
|
const p = Math.max(0, Math.min(pmax.value, fromY(pt.y)))
|
||||||
|
|
||||||
|
switch (dragging.value) {
|
||||||
|
case 'p2':
|
||||||
|
bp.a = localP0.value > 0 ? Math.max(0, Math.min(1, p / localP0.value)) : 0.5
|
||||||
|
break
|
||||||
|
case 'p3':
|
||||||
|
bp.b = bp.vinf > 0 ? Math.max(0, Math.min(1, v / bp.vinf)) : 0.5
|
||||||
|
break
|
||||||
|
case 'p4':
|
||||||
|
bp.vinf = Math.max(1, Math.min(vmax.value - 1, v))
|
||||||
|
break
|
||||||
|
case 'p5': {
|
||||||
|
const wmax = vmax.value - bp.vinf
|
||||||
|
bp.c = wmax > 0 ? Math.max(0, Math.min(1, (v - bp.vinf) / wmax)) : 0.5
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'p6': {
|
||||||
|
const wmax = vmax.value - bp.vinf
|
||||||
|
const qmax = pmax.value - localP0.value
|
||||||
|
bp.e = qmax > 0 ? Math.max(0, Math.min(1, (p - localP0.value) / qmax)) : 0.5
|
||||||
|
if (wmax > 0 && Math.abs(1 - bp.c) > 1e-10) {
|
||||||
|
const ratio = (v - bp.vinf) / wmax
|
||||||
|
bp.d = Math.max(0, Math.min(1, (1 - ratio) / (1 - bp.c)))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recalculate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalculate() {
|
||||||
|
if (!households.value.length) return
|
||||||
|
localP0.value = computeP0(
|
||||||
|
households.value, recettes.value, abop.value, abos.value,
|
||||||
|
bp.vinf, vmax.value, pmax.value, bp.a, bp.b, bp.c, bp.d, bp.e,
|
||||||
|
)
|
||||||
|
const result = computeImpacts(
|
||||||
|
households.value, recettes.value, abop.value, abos.value,
|
||||||
|
bp.vinf, vmax.value, pmax.value, bp.a, bp.b, bp.c, bp.d, bp.e,
|
||||||
|
)
|
||||||
|
impacts.value = result.impacts.map(imp => ({
|
||||||
|
volume: imp.volume,
|
||||||
|
old_price: imp.oldPrice,
|
||||||
|
new_price_rp: imp.newPriceRP,
|
||||||
|
new_price_rs: imp.newPriceRS,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
function debouncedServerCompute() {
|
||||||
|
if (serverTimeout) clearTimeout(serverTimeout)
|
||||||
|
serverTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.post<any>('/tariff/compute', {
|
||||||
|
commune_slug: slug, vinf: bp.vinf,
|
||||||
|
a: bp.a, b: bp.b, c: bp.c, d: bp.d, e: bp.e,
|
||||||
|
})
|
||||||
|
localP0.value = result.p0
|
||||||
|
impacts.value = result.impacts.map((imp: any) => ({
|
||||||
|
volume: imp.volume,
|
||||||
|
old_price: imp.old_price,
|
||||||
|
new_price_rp: imp.new_price_rp,
|
||||||
|
new_price_rs: imp.new_price_rs,
|
||||||
|
}))
|
||||||
|
} catch {}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chart 2: Baseline linear model ──
|
||||||
|
const W2 = 300
|
||||||
|
const H2 = 220
|
||||||
|
const margin2 = { top: 10, right: 10, bottom: 24, left: 40 }
|
||||||
|
const plotW2 = W2 - margin2.left - margin2.right
|
||||||
|
const plotH2 = H2 - margin2.top - margin2.bottom
|
||||||
|
|
||||||
|
function cx2(v: number) { return margin2.left + (v / vmax.value) * plotW2 }
|
||||||
|
|
||||||
|
const maxBill = computed(() => {
|
||||||
|
if (!curveData.value?.baseline_bills_rp?.length) return 500
|
||||||
|
const mx = Math.max(...curveData.value.baseline_bills_rp)
|
||||||
|
return Math.ceil(mx * 1.1 / 100) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
function cy2bill(b: number) { return margin2.top + plotH2 - (b / maxBill.value) * plotH2 }
|
||||||
|
function cy2price(p: number) { return margin2.top + plotH2 - (p / pmax.value) * plotH2 }
|
||||||
|
|
||||||
|
const gridVols2 = computed(() => {
|
||||||
|
const step = Math.ceil(vmax.value / 5 / 100) * 100
|
||||||
|
const arr: number[] = []
|
||||||
|
for (let v = step; v < vmax.value; v += step) arr.push(v)
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
const gridBills = computed(() => {
|
||||||
|
const step = Math.ceil(maxBill.value / 4 / 100) * 100
|
||||||
|
const arr: number[] = []
|
||||||
|
for (let b = step; b < maxBill.value; b += step) arr.push(b)
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
const gridPrices2 = computed(() => {
|
||||||
|
const step = Math.ceil(pmax.value / 4)
|
||||||
|
const arr: number[] = []
|
||||||
|
for (let p = step; p <= pmax.value; p += step) arr.push(p)
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
|
||||||
|
function toPolyline(vols: number[], vals: number[], cyFn: (v: number) => number) {
|
||||||
|
if (!vols?.length) return ''
|
||||||
|
// Downsample for performance (every 4th point)
|
||||||
|
return vols
|
||||||
|
.filter((_: number, i: number) => i % 4 === 0 || i === vols.length - 1)
|
||||||
|
.map((_: number, i: number) => {
|
||||||
|
const idx = i * 4 >= vols.length ? vols.length - 1 : i * 4
|
||||||
|
return `${cx2(vols[idx])},${cyFn(vals[idx])}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const baselineBillRP = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_bills_rp, cy2bill))
|
||||||
|
const baselineBillRS = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_bills_rs, cy2bill))
|
||||||
|
const baselinePriceRP = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_price_m3_rp, cy2price))
|
||||||
|
const baselinePriceRS = computed(() => toPolyline(curveData.value?.baseline_volumes, curveData.value?.baseline_price_m3_rs, cy2price))
|
||||||
|
|
||||||
|
// ── Auth & vote ──
|
||||||
|
async function authenticate() {
|
||||||
|
authError.value = ''
|
||||||
|
authLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.post<{ access_token: string; role: string; commune_slug: string }>(
|
||||||
|
'/auth/citizen/verify',
|
||||||
|
{ commune_slug: slug, auth_code: authCode.value.toUpperCase() },
|
||||||
|
)
|
||||||
|
authStore.setAuth(data.access_token, data.role, data.commune_slug)
|
||||||
|
} catch (e: any) {
|
||||||
|
authError.value = e.message || 'Code invalide'
|
||||||
|
} finally {
|
||||||
|
authLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitVote() {
|
||||||
|
submitting.value = true
|
||||||
|
voteSuccess.value = false
|
||||||
|
try {
|
||||||
|
await api.post(`/communes/${slug}/votes`, {
|
||||||
|
vinf: bp.vinf, a: bp.a, b: bp.b, c: bp.c, d: bp.d, e: bp.e,
|
||||||
|
})
|
||||||
|
voteSuccess.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.message || 'Erreur lors de la soumission')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load data ──
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [c, p, curve, pages] = await Promise.all([
|
||||||
|
api.get<any>(`/communes/${slug}`),
|
||||||
|
api.get<any>(`/communes/${slug}/params`),
|
||||||
|
api.get<any>(`/communes/${slug}/votes/current`),
|
||||||
|
api.get<any[]>(`/communes/${slug}/content`).catch(() => []),
|
||||||
|
])
|
||||||
|
contentPages.value = pages
|
||||||
|
commune.value = c
|
||||||
|
params.value = p
|
||||||
|
curveData.value = curve
|
||||||
|
|
||||||
|
// Set tariff params
|
||||||
|
vmax.value = p.vmax
|
||||||
|
pmax.value = p.pmax
|
||||||
|
recettes.value = p.recettes
|
||||||
|
abop.value = p.abop
|
||||||
|
abos.value = p.abos
|
||||||
|
|
||||||
|
// Set initial Bezier params from median (or default)
|
||||||
|
if (curve.median) {
|
||||||
|
bp.vinf = curve.median.vinf
|
||||||
|
bp.a = curve.median.a
|
||||||
|
bp.b = curve.median.b
|
||||||
|
bp.c = curve.median.c
|
||||||
|
bp.d = curve.median.d
|
||||||
|
bp.e = curve.median.e
|
||||||
|
}
|
||||||
|
localP0.value = curve.p0
|
||||||
|
|
||||||
|
// Set impacts from server
|
||||||
|
impacts.value = curve.impacts || []
|
||||||
|
|
||||||
|
// Build simplified household list for client-side compute
|
||||||
|
const stats = await api.get<any>(`/communes/${slug}/households/stats`)
|
||||||
|
const hh: HouseholdData[] = []
|
||||||
|
const avgVol = stats.avg_volume || 90
|
||||||
|
for (let i = 0; i < (stats.rs_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'RS' })
|
||||||
|
for (let i = 0; i < (stats.rp_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'RP' })
|
||||||
|
for (let i = 0; i < (stats.pro_count || 0); i++) hh.push({ volume_m3: avgVol, status: 'PRO' })
|
||||||
|
households.value = hh
|
||||||
|
} catch (e: any) {
|
||||||
|
loadError.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderMarkdown(md: string): string {
|
||||||
|
if (!md) return ''
|
||||||
|
return md
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||||
|
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||||
|
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
|
||||||
|
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||||
|
.replace(/\n\n/g, '</p><p>')
|
||||||
|
.replace(/^(?!<[hulo])(.+)$/gm, '<p>$1</p>')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
.param-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
.param-label { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||||
|
.param-val { font-family: monospace; font-weight: 600; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.table-sm { font-size: 0.8rem; }
|
||||||
|
.table-sm th, .table-sm td { padding: 0.25rem 0.5rem; }
|
||||||
|
|
||||||
|
.text-up { color: #dc2626; }
|
||||||
|
.text-down { color: #059669; }
|
||||||
|
|
||||||
|
.baseline-charts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.baseline-charts { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-5-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.grid-5-info { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
.info-label { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
.cms-body :deep(p) { margin: 0.5rem 0; }
|
||||||
|
.cms-body :deep(a) { color: var(--color-primary); }
|
||||||
|
.cms-body :deep(ul) { margin: 0.5rem 0; padding-left: 1.5rem; }
|
||||||
|
</style>
|
||||||
24
frontend/app/pages/commune/[slug]/vote.vue
Normal file
24
frontend/app/pages/commune/[slug]/vote.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div style="max-width: 600px; margin: 2rem auto; text-align: center;">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom: 1rem;">Vote enregistré</h2>
|
||||||
|
<p style="margin-bottom: 1.5rem;">
|
||||||
|
Votre vote a été soumis avec succès. Vous pouvez revenir à l'éditeur pour modifier votre choix
|
||||||
|
à tout moment (seul votre dernier vote sera pris en compte).
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 1rem; justify-content: center;">
|
||||||
|
<NuxtLink :to="`/commune/${slug}`" class="btn btn-primary">
|
||||||
|
Modifier mon vote
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/" class="btn btn-secondary">
|
||||||
|
Retour à l'accueil
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
</script>
|
||||||
119
frontend/app/pages/index.vue
Normal file
119
frontend/app/pages/index.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Tarification participative de l'eau</h1>
|
||||||
|
<p>
|
||||||
|
Dessinez votre courbe de tarification idéale et participez aux choix de votre commune.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Communes publiques -->
|
||||||
|
<section>
|
||||||
|
<h2 style="margin-bottom: 1rem;">Communes participantes</h2>
|
||||||
|
|
||||||
|
<div v-if="loading" style="text-align: center; padding: 2rem;">
|
||||||
|
<div class="spinner" style="margin: 0 auto;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="alert alert-error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="communes.length === 0" class="alert alert-info">
|
||||||
|
Aucune commune active pour le moment.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-3">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="commune in communes"
|
||||||
|
:key="commune.id"
|
||||||
|
:to="`/commune/${commune.slug}`"
|
||||||
|
class="card commune-card"
|
||||||
|
>
|
||||||
|
<h3>{{ commune.name }}</h3>
|
||||||
|
<p>{{ commune.description }}</p>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Accès administration -->
|
||||||
|
<section style="margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--color-border);">
|
||||||
|
<div class="grid grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Espace commune</h3>
|
||||||
|
<p style="margin: 0.75rem 0; color: var(--color-text-muted); font-size: 0.875rem;">
|
||||||
|
Vous êtes responsable d'une commune ? Connectez-vous pour gérer vos données,
|
||||||
|
paramétrer la tarification et consulter les votes.
|
||||||
|
</p>
|
||||||
|
<NuxtLink to="/login/commune" class="btn btn-secondary">Connexion commune</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Super administration</h3>
|
||||||
|
<p style="margin: 0.75rem 0; color: var(--color-text-muted); font-size: 0.875rem;">
|
||||||
|
Gestion globale des communes et des administrateurs.
|
||||||
|
</p>
|
||||||
|
<NuxtLink to="/login/admin" class="btn btn-secondary">Connexion admin</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const communes = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
communes.value = await api.get<any[]>('/communes/')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 0 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commune-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commune-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commune-card h3 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commune-card p {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
frontend/app/pages/login.vue
Normal file
46
frontend/app/pages/login.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div style="max-width: 500px; margin: 2rem auto;">
|
||||||
|
<div class="page-header" style="text-align: center;">
|
||||||
|
<h1>Connexion</h1>
|
||||||
|
<p style="color: var(--color-text-muted);">Choisissez votre espace.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-2">
|
||||||
|
<NuxtLink to="/login/commune" class="card login-choice">
|
||||||
|
<h3>Commune</h3>
|
||||||
|
<p>Gérer les données et la tarification de votre commune.</p>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/login/admin" class="card login-choice">
|
||||||
|
<h3>Super Admin</h3>
|
||||||
|
<p>Gestion globale des communes et administrateurs.</p>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="text-align: center; margin-top: 1.5rem;">
|
||||||
|
<NuxtLink to="/">← Retour à l'accueil</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-choice {
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-choice:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-choice h3 {
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-choice p {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
frontend/app/pages/login/admin.vue
Normal file
63
frontend/app/pages/login/admin.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div style="max-width: 420px; margin: 2rem auto;">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom: 0.5rem;">Super administration</h2>
|
||||||
|
<p style="color: var(--color-text-muted); margin-bottom: 1.5rem; font-size: 0.875rem;">
|
||||||
|
Gestion globale : création de communes, gestion des administrateurs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input v-model="email" type="email" class="form-input" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Mot de passe</label>
|
||||||
|
<input v-model="password" type="password" class="form-input" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading" style="width: 100%;">
|
||||||
|
<span v-if="loading" class="spinner" style="width: 1rem; height: 1rem;"></span>
|
||||||
|
<span v-else>Se connecter</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="text-align: center; margin-top: 1rem;">
|
||||||
|
<NuxtLink to="/">← Retour à l'accueil</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.post<{ access_token: string; role: string }>('/auth/admin/login', {
|
||||||
|
email: email.value,
|
||||||
|
password: password.value,
|
||||||
|
})
|
||||||
|
if (data.role !== 'super_admin') {
|
||||||
|
error.value = 'Ce compte n\'a pas les droits super admin. Utilisez la connexion commune.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authStore.setAuth(data.access_token, data.role)
|
||||||
|
router.push('/admin')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Erreur de connexion'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
64
frontend/app/pages/login/commune.vue
Normal file
64
frontend/app/pages/login/commune.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div style="max-width: 420px; margin: 2rem auto;">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom: 0.5rem;">Espace commune</h2>
|
||||||
|
<p style="color: var(--color-text-muted); margin-bottom: 1.5rem; font-size: 0.875rem;">
|
||||||
|
Connectez-vous pour gérer les données de votre commune.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input v-model="email" type="email" class="form-input" placeholder="contact@mairie.fr" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Mot de passe</label>
|
||||||
|
<input v-model="password" type="password" class="form-input" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading" style="width: 100%;">
|
||||||
|
<span v-if="loading" class="spinner" style="width: 1rem; height: 1rem;"></span>
|
||||||
|
<span v-else>Se connecter</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="text-align: center; margin-top: 1rem;">
|
||||||
|
<NuxtLink to="/">← Retour à l'accueil</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.post<{ access_token: string; role: string; commune_slug: string | null }>(
|
||||||
|
'/auth/admin/login',
|
||||||
|
{ email: email.value, password: password.value },
|
||||||
|
)
|
||||||
|
authStore.setAuth(data.access_token, data.role, data.commune_slug || undefined)
|
||||||
|
|
||||||
|
if (data.commune_slug) {
|
||||||
|
router.push(`/admin/communes/${data.commune_slug}`)
|
||||||
|
} else {
|
||||||
|
router.push('/admin')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Erreur de connexion'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
56
frontend/app/stores/auth.ts
Normal file
56
frontend/app/stores/auth.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null
|
||||||
|
role: string | null
|
||||||
|
communeSlug: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', {
|
||||||
|
state: (): AuthState => ({
|
||||||
|
token: null,
|
||||||
|
role: null,
|
||||||
|
communeSlug: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isAuthenticated: (state) => !!state.token,
|
||||||
|
isAdmin: (state) => state.role === 'super_admin' || state.role === 'commune_admin',
|
||||||
|
isSuperAdmin: (state) => state.role === 'super_admin',
|
||||||
|
isCitizen: (state) => state.role === 'citizen',
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
setAuth(token: string, role: string, communeSlug?: string) {
|
||||||
|
this.token = token
|
||||||
|
this.role = role
|
||||||
|
this.communeSlug = communeSlug || null
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.setItem('sejeteralo_token', token)
|
||||||
|
localStorage.setItem('sejeteralo_role', role)
|
||||||
|
if (communeSlug) localStorage.setItem('sejeteralo_commune', communeSlug)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.token = null
|
||||||
|
this.role = null
|
||||||
|
this.communeSlug = null
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.removeItem('sejeteralo_token')
|
||||||
|
localStorage.removeItem('sejeteralo_role')
|
||||||
|
localStorage.removeItem('sejeteralo_commune')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
restore() {
|
||||||
|
if (import.meta.client) {
|
||||||
|
this.token = localStorage.getItem('sejeteralo_token')
|
||||||
|
this.role = localStorage.getItem('sejeteralo_role')
|
||||||
|
this.communeSlug = localStorage.getItem('sejeteralo_commune')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
48
frontend/app/stores/commune.ts
Normal file
48
frontend/app/stores/commune.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
interface Commune {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TariffParams {
|
||||||
|
abop: number
|
||||||
|
abos: number
|
||||||
|
recettes: number
|
||||||
|
pmax: number
|
||||||
|
vmax: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommuneStore = defineStore('commune', {
|
||||||
|
state: () => ({
|
||||||
|
communes: [] as Commune[],
|
||||||
|
current: null as Commune | null,
|
||||||
|
params: null as TariffParams | null,
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchCommunes() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const api = useApi()
|
||||||
|
this.communes = await api.get<Commune[]>('/communes/')
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchCommune(slug: string) {
|
||||||
|
const api = useApi()
|
||||||
|
this.current = await api.get<Commune>(`/communes/${slug}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchParams(slug: string) {
|
||||||
|
const api = useApi()
|
||||||
|
this.params = await api.get<TariffParams>(`/communes/${slug}/params`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
368
frontend/app/utils/bezier-math.ts
Normal file
368
frontend/app/utils/bezier-math.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
/**
|
||||||
|
* TypeScript port of the Bézier tariff math engine.
|
||||||
|
*
|
||||||
|
* Mirrors backend/app/engine/integrals.py and pricing.py.
|
||||||
|
* Uses Cardano's formula + Newton-Raphson polish for cubic solving.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Cubic solver ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solve ax³ + bx² + cx + d = 0 for real roots in [0, 1].
|
||||||
|
* Uses Cardano's method with Newton-Raphson refinement.
|
||||||
|
*/
|
||||||
|
function solveCubicInUnit(a: number, b: number, c: number, d: number): number | null {
|
||||||
|
if (Math.abs(a) < 1e-12) {
|
||||||
|
// Degenerate: quadratic
|
||||||
|
if (Math.abs(b) < 1e-12) {
|
||||||
|
// Linear
|
||||||
|
if (Math.abs(c) < 1e-12) return null
|
||||||
|
const t = -d / c
|
||||||
|
return t >= -1e-10 && t <= 1 + 1e-10 ? clamp01(t) : null
|
||||||
|
}
|
||||||
|
const disc = c * c - 4 * b * d
|
||||||
|
if (disc < 0) return null
|
||||||
|
const sqrtDisc = Math.sqrt(disc)
|
||||||
|
const t1 = (-c + sqrtDisc) / (2 * b)
|
||||||
|
const t2 = (-c - sqrtDisc) / (2 * b)
|
||||||
|
if (t1 >= -1e-10 && t1 <= 1 + 1e-10) return clamp01(t1)
|
||||||
|
if (t2 >= -1e-10 && t2 <= 1 + 1e-10) return clamp01(t2)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize: t³ + pt² + qt + r = 0
|
||||||
|
const p = b / a
|
||||||
|
const q = c / a
|
||||||
|
const r = d / a
|
||||||
|
|
||||||
|
// Depressed cubic: u³ + pu + q = 0 via substitution t = u - p/3
|
||||||
|
const p1 = q - p * p / 3
|
||||||
|
const q1 = r - p * q / 3 + 2 * p * p * p / 27
|
||||||
|
|
||||||
|
const discriminant = q1 * q1 / 4 + p1 * p1 * p1 / 27
|
||||||
|
|
||||||
|
const roots: number[] = []
|
||||||
|
|
||||||
|
if (discriminant > 1e-12) {
|
||||||
|
// One real root
|
||||||
|
const sqrtD = Math.sqrt(discriminant)
|
||||||
|
const u = cbrt(-q1 / 2 + sqrtD)
|
||||||
|
const v = cbrt(-q1 / 2 - sqrtD)
|
||||||
|
roots.push(u + v - p / 3)
|
||||||
|
} else if (discriminant < -1e-12) {
|
||||||
|
// Three real roots (casus irreducibilis)
|
||||||
|
const m = Math.sqrt(-p1 / 3)
|
||||||
|
const theta = Math.acos((-q1 / 2) / (m * m * m)) / 3
|
||||||
|
roots.push(
|
||||||
|
2 * m * Math.cos(theta) - p / 3,
|
||||||
|
2 * m * Math.cos(theta - 2 * Math.PI / 3) - p / 3,
|
||||||
|
2 * m * Math.cos(theta - 4 * Math.PI / 3) - p / 3,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Double or triple root
|
||||||
|
const u = cbrt(-q1 / 2)
|
||||||
|
roots.push(2 * u - p / 3, -u - p / 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find root in [0,1] and refine with Newton-Raphson
|
||||||
|
for (const root of roots) {
|
||||||
|
if (root >= -0.1 && root <= 1.1) {
|
||||||
|
let t = clamp01(root)
|
||||||
|
// Newton-Raphson polish (3 iterations)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const f = ((a * t + b) * t + c) * t + d
|
||||||
|
const fp = (3 * a * t + 2 * b) * t + c
|
||||||
|
if (Math.abs(fp) < 1e-14) break
|
||||||
|
t = clamp01(t - f / fp)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cbrt(x: number): number {
|
||||||
|
return x < 0 ? -Math.pow(-x, 1 / 3) : Math.pow(x, 1 / 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(t: number): number {
|
||||||
|
return Math.max(0, Math.min(1, t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Integral computation ──
|
||||||
|
|
||||||
|
export interface IntegralResult {
|
||||||
|
alpha1: number
|
||||||
|
alpha2: number
|
||||||
|
beta2: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeIntegrals(
|
||||||
|
volume: number,
|
||||||
|
vinf: number,
|
||||||
|
vmax: number,
|
||||||
|
pmax: number,
|
||||||
|
a: number,
|
||||||
|
b: number,
|
||||||
|
c: number,
|
||||||
|
d: number,
|
||||||
|
e: number,
|
||||||
|
): IntegralResult {
|
||||||
|
if (volume <= vinf) {
|
||||||
|
const T = solveTier1T(volume, vinf, b)
|
||||||
|
const alpha1 = computeAlpha1(T, vinf, a, b)
|
||||||
|
return { alpha1, alpha2: 0, beta2: 0 }
|
||||||
|
} else {
|
||||||
|
const alpha1 = computeAlpha1(1.0, vinf, a, b)
|
||||||
|
const wmax = vmax - vinf
|
||||||
|
const T = solveTier2T(volume - vinf, wmax, c, d)
|
||||||
|
const uu = computeUU(T, c, d, e)
|
||||||
|
const alpha2 = (volume - vinf) - 3 * uu * wmax
|
||||||
|
const beta2 = 3 * pmax * wmax * uu
|
||||||
|
return { alpha1, alpha2, beta2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function solveTier1T(volume: number, vinf: number, b: number): number {
|
||||||
|
if (volume <= 0) return 0
|
||||||
|
if (volume >= vinf) return 1
|
||||||
|
const ratio = volume / vinf
|
||||||
|
const t = solveCubicInUnit(1 - 3 * b, 3 * b, 0, -ratio)
|
||||||
|
return t ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function solveTier2T(w: number, wmax: number, c: number, d: number): number {
|
||||||
|
if (w <= 0) return 0
|
||||||
|
if (w >= wmax) return 1
|
||||||
|
const ratio = w / wmax
|
||||||
|
const t = solveCubicInUnit(
|
||||||
|
3 * (c + d - c * d) - 2,
|
||||||
|
3 * (1 - 2 * c - d + c * d),
|
||||||
|
3 * c,
|
||||||
|
-ratio,
|
||||||
|
)
|
||||||
|
return t ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAlpha1(T: number, vinf: number, a: number, b: number): number {
|
||||||
|
return 3 * vinf * (
|
||||||
|
Math.pow(T, 6) / 6 * (-9 * a * b + 3 * a + 6 * b - 2) +
|
||||||
|
Math.pow(T, 5) / 5 * (24 * a * b - 6 * a - 13 * b + 3) +
|
||||||
|
3 * Math.pow(T, 4) / 4 * (-7 * a * b + a + 2 * b) +
|
||||||
|
Math.pow(T, 3) / 3 * 6 * a * b
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeUU(T: number, c: number, d: number, e: number): number {
|
||||||
|
return (
|
||||||
|
(-3 * c * d + 9 * e * c * d + 3 * c - 9 * e * c + 3 * d - 9 * e * d + 6 * e - 2) * Math.pow(T, 6) / 6 +
|
||||||
|
(2 * c * d - 15 * e * c * d - 4 * c + 21 * e * c - 2 * d + 15 * e * d - 12 * e + 2) * Math.pow(T, 5) / 5 +
|
||||||
|
(6 * e * c * d + c - 15 * e * c - 6 * e * d + 6 * e) * Math.pow(T, 4) / 4 +
|
||||||
|
(3 * e * c) * Math.pow(T, 3) / 3
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pricing computation ──
|
||||||
|
|
||||||
|
export interface HouseholdData {
|
||||||
|
volume_m3: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingResult {
|
||||||
|
p0: number
|
||||||
|
curveVolumes: number[]
|
||||||
|
curvePricesM3: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImpactRow {
|
||||||
|
volume: number
|
||||||
|
oldPrice: number
|
||||||
|
newPriceRP: number
|
||||||
|
newPriceRS: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeP0(
|
||||||
|
households: HouseholdData[],
|
||||||
|
recettes: number,
|
||||||
|
abop: number,
|
||||||
|
abos: number,
|
||||||
|
vinf: number,
|
||||||
|
vmax: number,
|
||||||
|
pmax: number,
|
||||||
|
a: number,
|
||||||
|
b: number,
|
||||||
|
c: number,
|
||||||
|
d: number,
|
||||||
|
e: number,
|
||||||
|
): number {
|
||||||
|
let totalAbo = 0
|
||||||
|
let totalAlpha = 0
|
||||||
|
let totalBeta = 0
|
||||||
|
|
||||||
|
for (const h of households) {
|
||||||
|
const abo = h.status === 'RS' ? abos : abop
|
||||||
|
totalAbo += abo
|
||||||
|
const vol = Math.max(h.volume_m3, 1e-5)
|
||||||
|
const { alpha1, alpha2, beta2 } = computeIntegrals(vol, vinf, vmax, pmax, a, b, c, d, e)
|
||||||
|
totalAlpha += alpha1 + alpha2
|
||||||
|
totalBeta += beta2
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalAbo >= recettes) return 0
|
||||||
|
if (totalAlpha === 0) return 0
|
||||||
|
|
||||||
|
return (recettes - totalAbo - totalBeta) / totalAlpha
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate price curve points (price per m³ vs volume).
|
||||||
|
*/
|
||||||
|
export function generateCurve(
|
||||||
|
vinf: number,
|
||||||
|
vmax: number,
|
||||||
|
pmax: number,
|
||||||
|
p0: number,
|
||||||
|
a: number,
|
||||||
|
b: number,
|
||||||
|
c: number,
|
||||||
|
d: number,
|
||||||
|
e: number,
|
||||||
|
nbpts: number = 200,
|
||||||
|
): PricingResult {
|
||||||
|
const curveVolumes: number[] = []
|
||||||
|
const curvePricesM3: number[] = []
|
||||||
|
|
||||||
|
const dt = 1 / (nbpts - 1)
|
||||||
|
|
||||||
|
// Tier 1
|
||||||
|
for (let i = 0; i < nbpts; i++) {
|
||||||
|
const t = Math.min(i * dt, 1 - 1e-6)
|
||||||
|
const v = vinf * ((1 - 3 * b) * t * t * t + 3 * b * t * t)
|
||||||
|
const p = p0 * ((3 * a - 2) * t * t * t + (-6 * a + 3) * t * t + 3 * a * t)
|
||||||
|
curveVolumes.push(v)
|
||||||
|
curvePricesM3.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2
|
||||||
|
for (let i = 0; i < nbpts; i++) {
|
||||||
|
const t = Math.min(i * dt, 1 - 1e-6)
|
||||||
|
const v = vinf + (vmax - vinf) * (
|
||||||
|
(3 * (c + d - c * d) - 2) * t * t * t +
|
||||||
|
3 * (1 - 2 * c - d + c * d) * t * t +
|
||||||
|
3 * c * t
|
||||||
|
)
|
||||||
|
const p = p0 + (pmax - p0) * ((1 - 3 * e) * t * t * t + 3 * e * t * t)
|
||||||
|
curveVolumes.push(v)
|
||||||
|
curvePricesM3.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { p0, curveVolumes, curvePricesM3 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute price impacts at reference volume levels.
|
||||||
|
*/
|
||||||
|
export function computeImpacts(
|
||||||
|
households: HouseholdData[],
|
||||||
|
recettes: number,
|
||||||
|
abop: number,
|
||||||
|
abos: number,
|
||||||
|
vinf: number,
|
||||||
|
vmax: number,
|
||||||
|
pmax: number,
|
||||||
|
a: number,
|
||||||
|
b: number,
|
||||||
|
c: number,
|
||||||
|
d: number,
|
||||||
|
e: number,
|
||||||
|
referenceVolumes: number[] = [30, 60, 90, 150, 300],
|
||||||
|
): { p0: number; impacts: ImpactRow[] } {
|
||||||
|
const p0 = computeP0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e)
|
||||||
|
|
||||||
|
// Linear baseline
|
||||||
|
const totalVol = households.reduce((s, h) => s + Math.max(h.volume_m3, 1e-5), 0)
|
||||||
|
const totalAbo = households.reduce((s, h) => s + (h.status === 'RS' ? abos : abop), 0)
|
||||||
|
const oldPM3 = totalVol > 0 ? (recettes - totalAbo) / totalVol : 0
|
||||||
|
|
||||||
|
const impacts: ImpactRow[] = referenceVolumes.map((vol) => {
|
||||||
|
const { alpha1, alpha2, beta2 } = computeIntegrals(vol, vinf, vmax, pmax, a, b, c, d, e)
|
||||||
|
return {
|
||||||
|
volume: vol,
|
||||||
|
oldPrice: abop + oldPM3 * vol,
|
||||||
|
newPriceRP: abop + (alpha1 + alpha2) * p0 + beta2,
|
||||||
|
newPriceRS: abos + (alpha1 + alpha2) * p0 + beta2,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { p0, impacts }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Control point mapping ──
|
||||||
|
|
||||||
|
export interface ControlPoints {
|
||||||
|
// Tier 1: P1(fixed), P2, P3, P4
|
||||||
|
p1: { x: number; y: number }
|
||||||
|
p2: { x: number; y: number }
|
||||||
|
p3: { x: number; y: number }
|
||||||
|
p4: { x: number; y: number }
|
||||||
|
// Tier 2: P4(shared), P5, P6, P7(fixed)
|
||||||
|
p5: { x: number; y: number }
|
||||||
|
p6: { x: number; y: number }
|
||||||
|
p7: { x: number; y: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paramsToControlPoints(
|
||||||
|
vinf: number,
|
||||||
|
vmax: number,
|
||||||
|
pmax: number,
|
||||||
|
p0: number,
|
||||||
|
a: number,
|
||||||
|
b: number,
|
||||||
|
c: number,
|
||||||
|
d: number,
|
||||||
|
e: number,
|
||||||
|
): ControlPoints {
|
||||||
|
return {
|
||||||
|
p1: { x: 0, y: 0 },
|
||||||
|
p2: { x: 0, y: a * p0 },
|
||||||
|
p3: { x: b * vinf, y: p0 },
|
||||||
|
p4: { x: vinf, y: p0 },
|
||||||
|
p5: { x: vinf + c * (vmax - vinf), y: p0 },
|
||||||
|
p6: {
|
||||||
|
x: vinf + (vmax - vinf) * (1 - d + c * d),
|
||||||
|
y: p0 + e * (pmax - p0),
|
||||||
|
},
|
||||||
|
p7: { x: vmax, y: pmax },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function controlPointsToParams(
|
||||||
|
cp: ControlPoints,
|
||||||
|
vmax: number,
|
||||||
|
pmax: number,
|
||||||
|
p0: number,
|
||||||
|
): { vinf: number; a: number; b: number; c: number; d: number; e: number } {
|
||||||
|
const vinf = cp.p4.x
|
||||||
|
|
||||||
|
const a = p0 > 0 ? clamp01(cp.p2.y / p0) : 0.5
|
||||||
|
const b = vinf > 0 ? clamp01(cp.p3.x / vinf) : 0.5
|
||||||
|
|
||||||
|
const wmax = vmax - vinf
|
||||||
|
const c = wmax > 0 ? clamp01((cp.p5.x - vinf) / wmax) : 0.5
|
||||||
|
|
||||||
|
const qmax = pmax - p0
|
||||||
|
const e = qmax > 0 ? clamp01((cp.p6.y - p0) / qmax) : 0.5
|
||||||
|
|
||||||
|
// d from p6.x: x6 = vinf + wmax * (1 - d + c*d) => d = (1 - (x6-vinf)/wmax) / (1-c)
|
||||||
|
let d_val = 0.5
|
||||||
|
if (wmax > 0) {
|
||||||
|
const ratio = (cp.p6.x - vinf) / wmax
|
||||||
|
if (Math.abs(1 - c) > 1e-10) {
|
||||||
|
d_val = clamp01((1 - ratio) / (1 - c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vinf, a, b, c, d: d_val, e }
|
||||||
|
}
|
||||||
30
frontend/nuxt.config.ts
Normal file
30
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-01-01',
|
||||||
|
future: { compatibilityVersion: 4 },
|
||||||
|
|
||||||
|
modules: ['@pinia/nuxt'],
|
||||||
|
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
devServer: {
|
||||||
|
port: 3009,
|
||||||
|
},
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBase: 'http://localhost:8000/api/v1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: 'SejeteralO - Tarification participative de l\'eau',
|
||||||
|
meta: [
|
||||||
|
{ charset: 'utf-8' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
9900
frontend/package-lock.json
generated
Normal file
9900
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "sejeteralo-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"generate": "nuxt generate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nuxt": "^4.3.1",
|
||||||
|
"vue": "^3.5.28",
|
||||||
|
"vue-router": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@pinia/nuxt": "^0.9.0",
|
||||||
|
"pinia": "^3.0.2",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/tsconfig.json
Normal file
3
frontend/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user