Files
pointeuse-optimisator/app/main.py
toto 6258fd3662 Ajoute colonnes Δ sem. / Fin cible, supprime solde global, indicateurs minima
- Nouvelles colonnes : Δ sem. (solde cumulé semaine) et Fin cible (heure de
  sortie aprem optimale pour 0h suppl. en fin de semaine)
- Δ sem. n'est affiché que quand la journée est complète et la chaîne de jours
  ininterrompue (pas de saut de jour non saisi)
- Fin cible disparaît quand la journée est validée (passée/complète)
- Delta = "—" pour les jours non travaillés (futur ou congé jour)
- Indicateur warn (fond rouge) sur matin/aprem si horaires < minimum
  (matin 09:00→12:00, aprem 14:00→17:00) ; mise à jour live via JS
- Suppression du solde global (3 cartes au lieu de 4)
- Tableau sans largeurs fixes + container 1100 px pour éviter scroll
  horizontal sur écrans normaux ; touch-scroll conservé sur mobile
- Base reste 39h/semaine (7h48/jour)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:35:17 +02:00

302 lines
12 KiB
Python

from datetime import datetime, timedelta
from itertools import groupby
from pathlib import Path
import shutil
import tempfile
from fastapi import FastAPI, Form, Request, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from models import (
load_config, load_week_pointages, save_week_pointages, load_conges,
toggle_conge, upsert_pointages, list_all_weeks,
)
from ods_import import parse_ods
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
app = FastAPI(title="Décompte Horaire")
templates = Jinja2Templates(directory="templates")
templates.env.filters["zip"] = zip
app.mount("/static", StaticFiles(directory="static"), name="static")
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
def week_dates(year: int, week: int) -> list[str]:
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)]
def heures_jour_min() -> int:
cfg = load_config()
return int(float(cfg.get("heures_jour", 7.8)) * 60)
def _weeks_by_year(all_weeks: list) -> dict:
"""Group sorted weeks by year, most recent first."""
result = {}
for yr, wks in groupby(sorted(all_weeks, reverse=True), key=lambda x: x[0]):
result[yr] = sorted(wks, key=lambda x: x[1], reverse=True)
return result
def _build_soldes_ctx(year: int, week: int, conges: list[dict], h_jour: int) -> dict:
"""Compute week result for the soldes partial."""
dates = week_dates(year, week)
raw = load_week_pointages(year, week)
p_by_date = {p["date"]: p for p in raw}
pointages = [
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
result = compute_week(pointages, conges, h_jour)
return {"result": result}
def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict:
"""Return template context for a single day row."""
from calcul import heures_travaillees
d = datetime.strptime(date, "%Y-%m-%d").date()
iso = d.isocalendar()
raw = load_week_pointages(iso.year, iso.week)
p_by_date = {p["date"]: p for p in raw}
p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None})
trav = heures_travaillees(p)
du = heures_dues(date, conges, h_jour)
delta = trav - du
return {
**p,
"travaille_min": trav, "du_min": du, "delta_min": delta,
"travaille": minutes_to_hhmm(trav),
"du": minutes_to_hhmm(du),
"delta": minutes_to_hhmm(delta),
"conge": {c["type"] for c in conges if c["date"] == date},
}
def _oob_calc(date: str, jour: dict) -> str:
"""Build OOB <span> fragments for the two calc cells (safe non-table elements)."""
trav_inner = f'<span class="calc-num">{"" if not jour["travaille_min"] else jour["travaille"]}</span>'
if jour["travaille_min"]:
delta_cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero")
sign = "+" if jour["delta_min"] > 0 else ""
delta_inner = f'<span class="{delta_cls}">{sign}{jour["delta"]}</span>'
else:
delta_inner = '<span class="delta-zero">—</span>'
return (
f'<span id="calc-{date}-trav" hx-swap-oob="true">{trav_inner}</span>'
f'<span id="calc-{date}-delta" hx-swap-oob="true">{delta_inner}</span>'
)
def _oob_week_extras(result: dict) -> str:
"""OOB <span> for all 5 days' cumulative delta and sortie cible."""
html = ""
for jour in result["jours"]:
date = jour["date"]
# Cumulative delta — only when chain of complete days is unbroken
cm = jour["delta_cumul_min"]
if jour.get("cumul_visible"):
if cm == 0:
cumul_inner = '<span class="delta-zero">0h00</span>'
else:
cls = "delta-pos" if cm > 0 else "delta-neg"
sign = "+" if cm > 0 else "-"
cumul_inner = f'<span class="{cls}">{sign}{jour["delta_cumul"]}</span>'
else:
cumul_inner = '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
# Sortie cible — only for incomplete days
cible = jour.get("sortie_cible") if not jour.get("is_complete") else None
cible_inner = f'<span class="cible-num">{cible}</span>' if cible else '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cible" hx-swap-oob="true">{cible_inner}</span>'
return html
def _oob_time_cells(date: str, jour: dict) -> str:
"""OOB <div> for matin/aprem cells with congé state and warn class."""
cg = jour["conge"]
matin_cg = "matin" in cg or "jour" in cg
aprem_cg = "aprem" in cg or "jour" in cg
matin_warn = not matin_cg and jour["du_min"] > 0 and (
(jour.get("matin_entree") and jour["matin_entree"] > "09:00") or
(jour.get("matin_sortie") and jour["matin_sortie"] < "12:00")
)
aprem_warn = not aprem_cg and jour["du_min"] > 0 and (
(jour.get("aprem_entree") and jour["aprem_entree"] > "14:00") or
(jour.get("aprem_sortie") and jour["aprem_sortie"] < "17:00")
)
matin_cls = "td-fill cg" if matin_cg else ("td-fill warn" if matin_warn else "td-fill")
aprem_cls = "td-fill cg" if aprem_cg else ("td-fill warn" if aprem_warn else "td-fill")
matin_html = (
f'<div id="cg-matin-{date}" hx-swap-oob="true" class="{matin_cls}">'
f'<div class="time-pair">'
f'<input class="time-input" type="time" name="matin_entree" data-date="{date}" value="{jour["matin_entree"] or ""}">'
f'<span class="time-sep">→</span>'
f'<input class="time-input" type="time" name="matin_sortie" data-date="{date}" value="{jour["matin_sortie"] or ""}">'
f'</div></div>'
)
aprem_html = (
f'<div id="cg-aprem-{date}" hx-swap-oob="true" class="{aprem_cls}">'
f'<div class="time-pair">'
f'<input class="time-input" type="time" name="aprem_entree" data-date="{date}" value="{jour["aprem_entree"] or ""}">'
f'<span class="time-sep">→</span>'
f'<input class="time-input" type="time" name="aprem_sortie" data-date="{date}" value="{jour["aprem_sortie"] or ""}">'
f'</div></div>'
)
return matin_html + aprem_html
def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse:
"""Return OOB <div>/<span> fragments for congé state + calc + soldes. No table elements."""
iso = d.isocalendar()
jour = _build_jour_ctx(date, conges, h_jour)
cg = jour["conge"]
btn_ma_on = " on" if "matin" in cg else ""
btn_am_on = " on" if "aprem" in cg else ""
btn_j_on = " jour-on" if "jour" in cg else ""
btns_html = (
f'<div id="cg-btn-{date}" hx-swap-oob="true">'
f'<div class="conge-btns">'
f'<button hx-post="/conge/{date}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>'
f'<button hx-post="/conge/{date}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>'
f'<button hx-post="/conge/{date}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
f'</div></div>'
)
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour)
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse(
_oob_time_cells(date, jour)
+ btns_html
+ _oob_calc(date, jour)
+ _oob_week_extras(soldes_ctx["result"])
+ soldes_html
)
def _htmx_calc_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse:
"""Return OOB <span>/<div> for calc cells, time cells, week extras, and soldes."""
iso = d.isocalendar()
jour = _build_jour_ctx(date, conges, h_jour)
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour)
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse(
_oob_calc(date, jour)
+ _oob_time_cells(date, jour)
+ _oob_week_extras(soldes_ctx["result"])
+ soldes_html
)
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
today = datetime.today()
iso = today.isocalendar()
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}")
@app.get("/semaine/{year}/{week}", response_class=HTMLResponse)
def semaine(request: Request, year: int, week: int):
h_jour = heures_jour_min()
conges = load_conges()
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour)
dates = week_dates(year, week)
raw = load_week_pointages(year, week)
p_by_date = {p["date"]: p for p in raw}
pointages = [
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
result = compute_week(pointages, conges, h_jour)
prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1)
next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1)
prev_iso = prev_d.isocalendar()
next_iso = next_d.isocalendar()
all_weeks = list_all_weeks()
return templates.TemplateResponse("semaine.html", {
"request": request,
"year": year, "week": week,
"result": result,
**soldes_ctx,
"prev_year": prev_iso.year, "prev_week": prev_iso.week,
"next_year": next_iso.year, "next_week": next_iso.week,
"jours_fr": JOURS_FR,
"all_weeks": all_weeks,
"weeks_by_year": _weeks_by_year(all_weeks),
})
@app.post("/pointage/{date}", response_class=HTMLResponse)
def save_pointage(
request: Request,
date: str,
matin_entree: str = Form(""),
matin_sortie: str = Form(""),
aprem_entree: str = Form(""),
aprem_sortie: str = Form(""),
):
def clean(v: str):
v = v.strip()
return v if v else None
d = datetime.strptime(date, "%Y-%m-%d").date()
iso = d.isocalendar()
raw = load_week_pointages(iso.year, iso.week)
p_by_date = {p["date"]: p for p in raw}
p_by_date[date] = {
"date": date,
"matin_entree": clean(matin_entree),
"matin_sortie": clean(matin_sortie),
"aprem_entree": clean(aprem_entree),
"aprem_sortie": clean(aprem_sortie),
}
save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]))
h_jour = heures_jour_min()
conges = load_conges()
return _htmx_calc_and_soldes(request, date, d, conges, h_jour)
@app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse)
def toggle_conge_route(date: str, type_conge: str, request: Request):
toggle_conge(date, type_conge)
d = datetime.strptime(date, "%Y-%m-%d").date()
if request.headers.get("HX-Request"):
h_jour = heures_jour_min()
conges = load_conges()
return _htmx_conge_and_soldes(request, date, d, conges, h_jour)
iso = d.isocalendar()
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303)
@app.get("/import", response_class=HTMLResponse)
def import_page(request: Request, msg: str = ""):
return templates.TemplateResponse("import.html", {"request": request, "msg": msg})
@app.post("/import")
async def do_import(request: Request, fichier: UploadFile = File(...)):
with tempfile.NamedTemporaryFile(suffix=".ods", delete=False) as tmp:
shutil.copyfileobj(fichier.file, tmp)
tmp_path = tmp.name
try:
records = parse_ods(tmp_path)
except Exception as e:
return RedirectResponse(f"/import?msg=Erreur+parsing:+{e}", status_code=303)
finally:
Path(tmp_path).unlink(missing_ok=True)
upsert_pointages(records)
return RedirectResponse(f"/import?msg={len(records)}+jours+importés", status_code=303)