diff --git a/docs/plans/2026-03-11-tableau-de-bord-travail.md b/docs/plans/2026-03-11-tableau-de-bord-travail.md new file mode 100644 index 0000000..884c4be --- /dev/null +++ b/docs/plans/2026-03-11-tableau-de-bord-travail.md @@ -0,0 +1,1644 @@ +# Tableau de bord temps de travail — Plan d'implémentation + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Application web Flask auto-hébergée pour tracer le temps de travail quotidien, les déplacements domicile-travail (CO2, frais réels fiscaux) et le solde congés/RTT. + +**Architecture:** Flask avec SQLAlchemy/SQLite pour la persistance, HTMX pour les interactions dynamiques sans JS custom, Tailwind CSS (CDN) pour le style mobile-first. Configuration véhicules/trajets/barème fiscal dans un fichier TOML rechargé au démarrage. + +**Tech Stack:** Python 3.12+, Flask, SQLAlchemy, tomllib (stdlib), pytest, pytest-flask, Gunicorn, HTMX, Tailwind CSS CDN + +--- + +## Référence design + +Voir `docs/plans/2026-03-11-tableau-de-bord-travail-design.md` pour le détail complet. + +**Types de journées :** `WORK | TT | GARDE | ASTREINTE | FORMATION | RTT | CONGE | MALADE | FERIE` + +**Référence horaire :** 7h45/jour, 38h45/semaine, GARDE = 10h (9h-19h) + +**Types sans déplacement :** `TT, MALADE, CONGE, RTT, FERIE` + +--- + +## Structure cible + +``` +tableau-de-bord/ +├── app/ +│ ├── __init__.py +│ ├── models.py +│ ├── config_loader.py +│ ├── business/ +│ │ ├── __init__.py +│ │ ├── time_calc.py +│ │ ├── travel_calc.py +│ │ └── leave_calc.py +│ └── routes/ +│ ├── __init__.py +│ ├── dashboard.py +│ ├── entries.py +│ └── reports.py +│ └── templates/ +│ ├── base.html +│ ├── dashboard.html +│ ├── entry_form.html +│ ├── entry_list.html +│ └── reports.html +├── tests/ +│ ├── conftest.py +│ ├── test_config_loader.py +│ ├── test_time_calc.py +│ ├── test_travel_calc.py +│ └── test_leave_calc.py +├── config.toml +├── requirements.txt +└── run.py +``` + +--- + +## Task 1 : Setup du projet + +**Files:** +- Create: `requirements.txt` +- Create: `run.py` +- Create: `app/__init__.py` +- Create: `tests/conftest.py` + +**Step 1: Créer `requirements.txt`** + +``` +flask>=3.0 +flask-sqlalchemy>=3.1 +pytest>=8.0 +pytest-flask>=1.3 +gunicorn>=22.0 +``` + +**Step 2: Créer `run.py`** + +```python +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(debug=True) +``` + +**Step 3: Créer `app/__init__.py`** + +```python +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +import tomllib +import os + +db = SQLAlchemy() + + +def create_app(config_path=None): + app = Flask(__name__, instance_relative_config=True) + + os.makedirs(app.instance_path, exist_ok=True) + + app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod") + + # Load TOML config + if config_path is None: + config_path = os.path.join(os.path.dirname(app.root_path), "config.toml") + if os.path.exists(config_path): + with open(config_path, "rb") as f: + app.config["TOML"] = tomllib.load(f) + else: + app.config["TOML"] = {} + + db.init_app(app) + + from app.routes.dashboard import bp as dashboard_bp + from app.routes.entries import bp as entries_bp + from app.routes.reports import bp as reports_bp + app.register_blueprint(dashboard_bp) + app.register_blueprint(entries_bp) + app.register_blueprint(reports_bp) + + with app.app_context(): + db.create_all() + + return app +``` + +**Step 4: Créer `tests/conftest.py`** + +```python +import pytest +from app import create_app, db as _db +import tempfile, os + + +@pytest.fixture +def app(tmp_path): + config_path = tmp_path / "config.toml" + config_path.write_text(""" +[vehicles.voiture] +name = "Peugeot 308" +fuel = "diesel" +co2_per_km = 142 +cv = 5 + +[vehicles.velo] +name = "Vélo" +fuel = "none" +co2_per_km = 0 + +[journeys.voiture_seule] +name = "Voiture seule" +distances = { voiture = 25 } + +[journeys.voiture_velo] +name = "Voiture + Vélo" +distances = { voiture = 14, velo = 8 } + +[journeys.velo_seul] +name = "Vélo seul" +distances = { velo = 24 } + +[bareme_kilometrique.2025.cv_5.tranches] +data = [ + { km_max = 3000, taux = 0.548, forfait = 0 }, + { km_max = 6000, taux = 0.316, forfait = 699 }, + { km_max = 0, taux = 0.364, forfait = 0 }, +] +""", encoding="utf-8") + + application = create_app(config_path=str(config_path)) + application.config["TESTING"] = True + application.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" + + with application.app_context(): + _db.create_all() + yield application + _db.drop_all() + + +@pytest.fixture +def client(app): + return app.test_client() +``` + +**Step 5: Installer les dépendances** + +```bash +pip install -r requirements.txt +``` + +**Step 6: Vérifier que Flask démarre** + +```bash +python run.py +``` +Expected: serveur démarré sur http://127.0.0.1:5000 + +**Step 7: Commit** + +```bash +git add requirements.txt run.py app/ tests/ +git commit -m "feat: initial Flask project setup with factory pattern" +``` + +--- + +## Task 2 : Configuration TOML + fichier exemple + +**Files:** +- Create: `config.toml` +- Create: `app/config_loader.py` +- Create: `tests/test_config_loader.py` + +**Step 1: Créer `config.toml`** + +```toml +[vehicles.voiture] +name = "Peugeot 308" +fuel = "diesel" +co2_per_km = 142 +cv = 5 + +[vehicles.velo] +name = "Vélo" +fuel = "none" +co2_per_km = 0 + +[journeys.voiture_seule] +name = "Voiture seule" +distances = { voiture = 25 } + +[journeys.voiture_velo] +name = "Voiture + Vélo" +distances = { voiture = 14, velo = 8 } + +[journeys.velo_seul] +name = "Vélo seul" +distances = { velo = 24 } + +# Barème kilométrique officiel. +# km_max = 0 signifie "pas de limite" (dernière tranche). +# Formule : frais = km * taux + forfait + +[bareme_kilometrique.2025.cv_5] +[[bareme_kilometrique.2025.cv_5.tranches]] +km_max = 3000 +taux = 0.548 +forfait = 0 + +[[bareme_kilometrique.2025.cv_5.tranches]] +km_max = 6000 +taux = 0.316 +forfait = 699 + +[[bareme_kilometrique.2025.cv_5.tranches]] +km_max = 0 +taux = 0.364 +forfait = 0 + +[bareme_kilometrique.2025.cv_6_7] +[[bareme_kilometrique.2025.cv_6_7.tranches]] +km_max = 3000 +taux = 0.655 +forfait = 0 + +[[bareme_kilometrique.2025.cv_6_7.tranches]] +km_max = 6000 +taux = 0.374 +forfait = 836 + +[[bareme_kilometrique.2025.cv_6_7.tranches]] +km_max = 0 +taux = 0.435 +forfait = 0 +``` + +**Step 2: Créer `app/config_loader.py`** + +```python +from flask import current_app + + +def get_vehicles(): + """Retourne le dict des véhicules depuis la config TOML.""" + return current_app.config.get("TOML", {}).get("vehicles", {}) + + +def get_journeys(): + """Retourne le dict des profils de trajet depuis la config TOML.""" + return current_app.config.get("TOML", {}).get("journeys", {}) + + +def get_bareme(year: int, cv: int) -> list[dict]: + """ + Retourne les tranches du barème kilométrique pour une année et une puissance fiscale. + cv : puissance fiscale (ex: 5 → clé 'cv_5', 6 ou 7 → clé 'cv_6_7') + Retourne [] si non trouvé. + """ + bareme = current_app.config.get("TOML", {}).get("bareme_kilometrique", {}) + year_data = bareme.get(str(year), {}) + + if cv <= 5: + key = "cv_5" + elif cv <= 7: + key = "cv_6_7" + elif cv <= 9: + key = "cv_8_9" + else: + key = "cv_10_11" + + return year_data.get(key, {}).get("tranches", []) + + +def day_types_without_journey(): + """Types de journées sans déplacement domicile-travail.""" + return {"TT", "MALADE", "CONGE", "RTT", "FERIE"} +``` + +**Step 3: Écrire les tests `tests/test_config_loader.py`** + +```python +def test_get_vehicles_returns_configured_vehicles(app): + with app.app_context(): + from app.config_loader import get_vehicles + vehicles = get_vehicles() + assert "voiture" in vehicles + assert vehicles["voiture"]["co2_per_km"] == 142 + + +def test_get_journeys_returns_profiles(app): + with app.app_context(): + from app.config_loader import get_journeys + journeys = get_journeys() + assert "voiture_seule" in journeys + assert journeys["voiture_seule"]["distances"]["voiture"] == 25 + + +def test_get_bareme_returns_tranches(app): + with app.app_context(): + from app.config_loader import get_bareme + tranches = get_bareme(2025, 5) + assert len(tranches) == 3 + assert tranches[0]["taux"] == 0.548 + + +def test_day_types_without_journey(app): + with app.app_context(): + from app.config_loader import day_types_without_journey + types = day_types_without_journey() + assert "TT" in types + assert "WORK" not in types +``` + +**Step 4: Lancer les tests** + +```bash +pytest tests/test_config_loader.py -v +``` +Expected: 4 PASSED + +**Step 5: Commit** + +```bash +git add config.toml app/config_loader.py tests/test_config_loader.py +git commit -m "feat: TOML config loader for vehicles, journeys, and tax scales" +``` + +--- + +## Task 3 : Modèles SQLAlchemy + +**Files:** +- Create: `app/models.py` + +**Step 1: Créer `app/models.py`** + +```python +from app import db +from datetime import date, time, datetime +from enum import Enum as PyEnum +import sqlalchemy as sa +import sqlalchemy.orm as so + + +class DayType(PyEnum): + WORK = "WORK" + TT = "TT" + GARDE = "GARDE" + ASTREINTE = "ASTREINTE" + FORMATION = "FORMATION" + RTT = "RTT" + CONGE = "CONGE" + MALADE = "MALADE" + FERIE = "FERIE" + + +class WorkEntry(db.Model): + __tablename__ = "work_entries" + + id: so.Mapped[int] = so.mapped_column(primary_key=True) + date: so.Mapped[date] = so.mapped_column(sa.Date, unique=True, nullable=False) + journey_profile_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True) + day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK") + comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True) + created_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, default=datetime.utcnow) + updated_at: so.Mapped[datetime] = so.mapped_column( + sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + + time_slots: so.Mapped[list["TimeSlot"]] = so.relationship( + back_populates="entry", cascade="all, delete-orphan", order_by="TimeSlot.start_time" + ) + + def total_minutes(self) -> int: + """Somme des plages horaires en minutes.""" + total = 0 + for slot in self.time_slots: + start = slot.start_time.hour * 60 + slot.start_time.minute + end = slot.end_time.hour * 60 + slot.end_time.minute + # Gère le passage minuit (ex: 22h-00h) + if end <= start: + end += 24 * 60 + total += end - start + return total + + def total_hours_str(self) -> str: + """Retourne le temps total formaté en 'Xh YYmin'.""" + minutes = self.total_minutes() + return f"{minutes // 60}h{minutes % 60:02d}" + + +class TimeSlot(db.Model): + __tablename__ = "time_slots" + + id: so.Mapped[int] = so.mapped_column(primary_key=True) + entry_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey("work_entries.id"), nullable=False) + start_time: so.Mapped[time] = so.mapped_column(sa.Time, nullable=False) + end_time: so.Mapped[time] = so.mapped_column(sa.Time, nullable=False) + + entry: so.Mapped["WorkEntry"] = so.relationship(back_populates="time_slots") + + +class LeaveBalance(db.Model): + __tablename__ = "leave_balance" + + id: so.Mapped[int] = so.mapped_column(primary_key=True) + year: so.Mapped[int] = so.mapped_column(sa.Integer, unique=True, nullable=False) + conges_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=28) + rtt_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=18) +``` + +**Step 2: Vérifier la création des tables** + +```bash +python -c "from app import create_app, db; app = create_app(); app.app_context().push(); db.create_all(); print('Tables OK')" +``` +Expected: `Tables OK` + +**Step 3: Commit** + +```bash +git add app/models.py +git commit -m "feat: SQLAlchemy models for WorkEntry, TimeSlot, LeaveBalance" +``` + +--- + +## Task 4 : Business logic — calcul du temps + +**Files:** +- Create: `app/business/__init__.py` +- Create: `app/business/time_calc.py` +- Create: `tests/test_time_calc.py` + +**Step 1: Créer `app/business/__init__.py`** (vide) + +**Step 2: Écrire les tests `tests/test_time_calc.py`** + +```python +from app.business.time_calc import ( + minutes_to_str, + work_minutes_reference, + week_balance_minutes, +) +from datetime import date + + +def test_minutes_to_str_basic(): + assert minutes_to_str(465) == "7h45" + + +def test_minutes_to_str_zero(): + assert minutes_to_str(0) == "0h00" + + +def test_work_minutes_reference_normal_day(): + # WORK, TT, FORMATION, GARDE non-weekend = 7h45 = 465 min + assert work_minutes_reference("WORK") == 465 + assert work_minutes_reference("TT") == 465 + assert work_minutes_reference("FORMATION") == 465 + + +def test_work_minutes_reference_garde(): + # GARDE = 10h = 600 min + assert work_minutes_reference("GARDE") == 600 + + +def test_work_minutes_reference_absence(): + # RTT, CONGE, MALADE, FERIE = 0 (pas de temps à faire) + assert work_minutes_reference("RTT") == 0 + assert work_minutes_reference("CONGE") == 0 + assert work_minutes_reference("MALADE") == 0 + assert work_minutes_reference("FERIE") == 0 + + +def test_week_balance_positive(): + # 40h travaillées vs 38h45 référence = +75 min + balance = week_balance_minutes(actual_minutes=2400, reference_minutes=2325) + assert balance == 75 + + +def test_week_balance_negative(): + balance = week_balance_minutes(actual_minutes=2200, reference_minutes=2325) + assert balance == -125 +``` + +**Step 3: Lancer les tests pour vérifier qu'ils échouent** + +```bash +pytest tests/test_time_calc.py -v +``` +Expected: ImportError ou FAILED + +**Step 4: Créer `app/business/time_calc.py`** + +```python +def minutes_to_str(minutes: int) -> str: + """Convertit des minutes en chaîne 'Xh YY'.""" + sign = "-" if minutes < 0 else "" + minutes = abs(minutes) + return f"{sign}{minutes // 60}h{minutes % 60:02d}" + + +# Minutes de référence par type de journée +_REFERENCE_MINUTES = { + "WORK": 465, # 7h45 + "TT": 465, + "FORMATION": 465, + "GARDE": 600, # 10h (9h-19h) + "ASTREINTE": 0, # variable selon time_slots + "RTT": 0, + "CONGE": 0, + "MALADE": 0, + "FERIE": 0, +} + + +def work_minutes_reference(day_type: str) -> int: + """Retourne le nombre de minutes de référence pour un type de journée.""" + return _REFERENCE_MINUTES.get(day_type, 465) + + +def week_balance_minutes(actual_minutes: int, reference_minutes: int) -> int: + """Retourne l'écart en minutes entre temps réel et référence.""" + return actual_minutes - reference_minutes +``` + +**Step 5: Lancer les tests** + +```bash +pytest tests/test_time_calc.py -v +``` +Expected: 7 PASSED + +**Step 6: Commit** + +```bash +git add app/business/ tests/test_time_calc.py +git commit -m "feat: time calculation business logic" +``` + +--- + +## Task 5 : Business logic — calcul déplacements (km, CO2, frais réels) + +**Files:** +- Create: `app/business/travel_calc.py` +- Create: `tests/test_travel_calc.py` + +**Step 1: Écrire les tests `tests/test_travel_calc.py`** + +```python +from app.business.travel_calc import ( + compute_km_for_entry, + compute_co2_grams, + compute_frais_reels, +) + + +VEHICLES = { + "voiture": {"co2_per_km": 142, "cv": 5}, + "velo": {"co2_per_km": 0}, +} + +JOURNEYS = { + "voiture_seule": {"distances": {"voiture": 25}}, + "voiture_velo": {"distances": {"voiture": 14, "velo": 8}}, + "velo_seul": {"distances": {"velo": 24}}, +} + +TRANCHES_CV5 = [ + {"km_max": 3000, "taux": 0.548, "forfait": 0}, + {"km_max": 6000, "taux": 0.316, "forfait": 699}, + {"km_max": 0, "taux": 0.364, "forfait": 0}, +] + + +def test_compute_km_voiture_seule(): + km = compute_km_for_entry("voiture_seule", JOURNEYS) + assert km == {"voiture": 25} + + +def test_compute_km_voiture_velo(): + km = compute_km_for_entry("voiture_velo", JOURNEYS) + assert km == {"voiture": 14, "velo": 8} + + +def test_compute_km_no_journey(): + km = compute_km_for_entry(None, JOURNEYS) + assert km == {} + + +def test_compute_co2_voiture(): + co2 = compute_co2_grams({"voiture": 25}, VEHICLES) + assert co2 == 25 * 142 # 3550g + + +def test_compute_co2_velo(): + co2 = compute_co2_grams({"velo": 24}, VEHICLES) + assert co2 == 0 + + +def test_frais_reels_tranche1(): + # 2000 km → tranche 1 → 2000 * 0.548 = 1096€ + result = compute_frais_reels(2000, TRANCHES_CV5) + assert abs(result - 1096.0) < 0.01 + + +def test_frais_reels_tranche2(): + # 4000 km → tranche 2 → 4000 * 0.316 + 699 = 1963€ + result = compute_frais_reels(4000, TRANCHES_CV5) + assert abs(result - 1963.0) < 0.01 + + +def test_frais_reels_tranche3(): + # 7000 km → tranche 3 → 7000 * 0.364 = 2548€ + result = compute_frais_reels(7000, TRANCHES_CV5) + assert abs(result - 2548.0) < 0.01 +``` + +**Step 2: Lancer pour vérifier l'échec** + +```bash +pytest tests/test_travel_calc.py -v +``` +Expected: ImportError + +**Step 3: Créer `app/business/travel_calc.py`** + +```python +def compute_km_for_entry(journey_profile_id: str | None, journeys: dict) -> dict[str, int]: + """ + Retourne un dict {vehicle_id: km} pour un profil de trajet donné. + Retourne {} si pas de profil (TT, CONGE, etc.). + """ + if not journey_profile_id: + return {} + profile = journeys.get(journey_profile_id, {}) + return dict(profile.get("distances", {})) + + +def compute_co2_grams(km_by_vehicle: dict[str, int], vehicles: dict) -> float: + """Calcule le CO2 total en grammes pour un dict {vehicle_id: km}.""" + total = 0.0 + for vehicle_id, km in km_by_vehicle.items(): + vehicle = vehicles.get(vehicle_id, {}) + co2 = vehicle.get("co2_per_km", 0) + total += km * co2 + return total + + +def compute_frais_reels(total_km_voiture: float, tranches: list[dict]) -> float: + """ + Calcule les frais réels fiscaux selon le barème kilométrique. + Formule : km * taux + forfait pour la tranche applicable. + km_max = 0 signifie "pas de limite" (dernière tranche). + """ + if not tranches or total_km_voiture <= 0: + return 0.0 + + for tranche in tranches: + km_max = tranche["km_max"] + if km_max == 0 or total_km_voiture <= km_max: + return total_km_voiture * tranche["taux"] + tranche.get("forfait", 0) + + # Fallback : dernière tranche + last = tranches[-1] + return total_km_voiture * last["taux"] + last.get("forfait", 0) +``` + +**Step 4: Lancer les tests** + +```bash +pytest tests/test_travel_calc.py -v +``` +Expected: 7 PASSED + +**Step 5: Commit** + +```bash +git add app/business/travel_calc.py tests/test_travel_calc.py +git commit -m "feat: travel calculation (km, CO2, frais réels fiscaux)" +``` + +--- + +## Task 6 : Business logic — solde congés/RTT + +**Files:** +- Create: `app/business/leave_calc.py` +- Create: `tests/test_leave_calc.py` + +**Step 1: Écrire les tests `tests/test_leave_calc.py`** + +```python +from app.business.leave_calc import compute_leave_used, get_or_create_balance +from app.models import WorkEntry, LeaveBalance +from app import db +from datetime import date + + +def test_compute_leave_used_conges(app): + with app.app_context(): + entries = [ + WorkEntry(date=date(2025, 1, 6), day_type="CONGE"), + WorkEntry(date=date(2025, 1, 7), day_type="CONGE"), + WorkEntry(date=date(2025, 1, 8), day_type="RTT"), + WorkEntry(date=date(2025, 1, 9), day_type="WORK"), + ] + for e in entries: + db.session.add(e) + db.session.commit() + + used = compute_leave_used(2025) + assert used["conges"] == 2 + assert used["rtt"] == 1 + + +def test_get_or_create_balance_creates_default(app): + with app.app_context(): + balance = get_or_create_balance(2025) + assert balance.year == 2025 + assert balance.conges_total == 28 + assert balance.rtt_total == 18 + + +def test_get_or_create_balance_returns_existing(app): + with app.app_context(): + existing = LeaveBalance(year=2025, conges_total=25, rtt_total=15) + db.session.add(existing) + db.session.commit() + + balance = get_or_create_balance(2025) + assert balance.conges_total == 25 + assert balance.rtt_total == 15 +``` + +**Step 2: Lancer pour vérifier l'échec** + +```bash +pytest tests/test_leave_calc.py -v +``` +Expected: ImportError + +**Step 3: Créer `app/business/leave_calc.py`** + +```python +from app import db +from app.models import WorkEntry, LeaveBalance +import sqlalchemy as sa +from datetime import date + + +def compute_leave_used(year: int) -> dict[str, int]: + """ + Calcule dynamiquement les congés et RTT utilisés pour une année donnée. + Retourne {"conges": N, "rtt": N}. + """ + start = date(year, 1, 1) + end = date(year, 12, 31) + + conges = db.session.scalar( + sa.select(sa.func.count()).where( + WorkEntry.date.between(start, end), + WorkEntry.day_type == "CONGE", + ) + ) or 0 + + rtt = db.session.scalar( + sa.select(sa.func.count()).where( + WorkEntry.date.between(start, end), + WorkEntry.day_type == "RTT", + ) + ) or 0 + + return {"conges": conges, "rtt": rtt} + + +def get_or_create_balance(year: int) -> LeaveBalance: + """Récupère ou crée un solde de congés pour l'année donnée.""" + balance = db.session.scalar( + sa.select(LeaveBalance).where(LeaveBalance.year == year) + ) + if balance is None: + balance = LeaveBalance(year=year) + db.session.add(balance) + db.session.commit() + return balance +``` + +**Step 4: Lancer les tests** + +```bash +pytest tests/test_leave_calc.py -v +``` +Expected: 3 PASSED + +**Step 5: Lancer tous les tests** + +```bash +pytest -v +``` +Expected: tous PASSED + +**Step 6: Commit** + +```bash +git add app/business/leave_calc.py tests/test_leave_calc.py +git commit -m "feat: leave balance calculation (congés/RTT)" +``` + +--- + +## Task 7 : Routes + templates de base + +**Files:** +- Create: `app/routes/__init__.py` +- Create: `app/routes/dashboard.py` +- Create: `app/routes/entries.py` +- Create: `app/routes/reports.py` +- Create: `app/templates/base.html` + +**Step 1: Créer `app/routes/__init__.py`** (vide) + +**Step 2: Créer `app/templates/base.html`** + +```html + + +
+ + +{{ today_entry.day_type }} — {{ today_entry.total_hours_str() }}
+ Modifier + {% else %} + + + Saisir ma journée + + {% endif %} +Aucune entrée pour le moment.
+{% endif %} + +{{ entry.date.strftime('%a %d %b %Y') }}
++ {{ entry.day_type }} + {% if entry.time_slots %} — {{ entry.total_hours_str() }}{% endif %} + {% if entry.journey_profile_id %} — {{ entry.journey_profile_id }}{% endif %} +
+ {% if entry.comment %} +{{ entry.comment }}
+ {% endif %} +Déduction fiscale estimée — véhicules motorisés uniquement
+ {% for vehicle_id, montant in frais_reels.items() %} +Aucune donnée pour {{ year }}.
+ {% endif %} +