App web de suivi des heures de travail (FastAPI + HTMX + JSON plats). - Saisie inline par semaine avec bouton ✓ par ligne - Congés MA/AM/J avec coloration cellule et mise à jour live via OOB HTMX - Import fichier ODS badgeuse - Solde semaine et solde global recalculés à la volée - Design "ledger" écru, DM Mono, zéro border-radius - Déploiement Docker sur port 8000 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
266 lines
11 KiB
Python
266 lines
11 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 + global solde 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)
|
|
|
|
all_weeks = list_all_weeks()
|
|
total_t = total_d = 0
|
|
for yw in all_weeks:
|
|
for p in load_week_pointages(*yw):
|
|
for f, g in [("matin_sortie", "matin_entree"), ("aprem_sortie", "aprem_entree")]:
|
|
if p.get(f) and p.get(g):
|
|
total_t += max(0, hhmm_to_minutes(p[f]) - hhmm_to_minutes(p[g]))
|
|
total_d += heures_dues(p["date"], conges, h_jour)
|
|
|
|
return {
|
|
"result": result,
|
|
"solde_global": minutes_to_hhmm(total_t - total_d),
|
|
"solde_global_min": total_t - total_d,
|
|
}
|
|
|
|
|
|
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>'
|
|
delta_cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero")
|
|
if jour["travaille_min"] or jour["du_min"]:
|
|
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 _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"]
|
|
|
|
cg_matin = "matin" in cg or "jour" in cg
|
|
cg_aprem = "aprem" in cg or "jour" in cg
|
|
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 ""
|
|
|
|
matin_html = (
|
|
f'<div id="cg-matin-{date}" hx-swap-oob="true" class="td-fill{" cg" if cg_matin else ""}">'
|
|
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="td-fill{" cg" if cg_aprem else ""}">'
|
|
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>'
|
|
)
|
|
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(matin_html + aprem_html + btns_html + _oob_calc(date, jour) + soldes_html)
|
|
|
|
|
|
def _htmx_calc_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse:
|
|
"""Return OOB <span> for calc cells + OOB <div> for soldes. No table elements."""
|
|
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) + 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)
|