Initial commit — décompte horaire

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>
This commit is contained in:
toto
2026-06-24 15:08:32 +02:00
commit 6f6c7f154e
14 changed files with 1398 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
data/
*.ods
.~lock.*
__pycache__/
*.pyc
.env

14
Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ .
VOLUME ["/data"]
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

80
app/calcul.py Normal file
View File

@ -0,0 +1,80 @@
"""Business logic: compute worked hours and expected hours per day/week."""
from datetime import datetime, timedelta
def hhmm_to_minutes(val: str | None) -> int:
if not val:
return 0
h, m = val.split(":")
return int(h) * 60 + int(m)
def minutes_to_hhmm(minutes: int) -> str:
sign = "-" if minutes < 0 else ""
minutes = abs(minutes)
return f"{sign}{minutes // 60}h{minutes % 60:02d}"
def heures_travaillees(p: dict) -> int:
"""Return worked minutes for a pointage row."""
matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - hhmm_to_minutes(p["matin_entree"]))
aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - hhmm_to_minutes(p["aprem_entree"]))
# only count if entry AND exit are present
if not (p["matin_entree"] and p["matin_sortie"]):
matin = 0
if not (p["aprem_entree"] and p["aprem_sortie"]):
aprem = 0
return matin + aprem
def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int) -> int:
"""Return expected minutes for a date, accounting for vacations."""
d = datetime.strptime(date_str, "%Y-%m-%d").date()
# Weekend = no hours due
if d.weekday() >= 5:
return 0
conges_date = {c["type"] for c in conges if c["date"] == date_str}
if "jour" in conges_date:
return 0
if "matin" in conges_date and "aprem" in conges_date:
return 0
half = heures_jour_min // 2
if "matin" in conges_date or "aprem" in conges_date:
return half
return heures_jour_min
def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int) -> dict:
"""Compute totals for a list of pointages (one week)."""
total_travaille = 0
total_du = 0
jours = []
for p in pointages:
travaille = heures_travaillees(p)
du = heures_dues(p["date"], conges, heures_jour_min)
delta = travaille - du
total_travaille += travaille
total_du += du
jours.append({
**p,
"travaille_min": travaille,
"du_min": du,
"delta_min": delta,
"travaille": minutes_to_hhmm(travaille),
"du": minutes_to_hhmm(du),
"delta": minutes_to_hhmm(delta),
"conge": {c["type"] for c in conges if c["date"] == p["date"]},
})
return {
"jours": jours,
"total_travaille": minutes_to_hhmm(total_travaille),
"total_du": minutes_to_hhmm(total_du),
"solde": minutes_to_hhmm(total_travaille - total_du),
"solde_min": total_travaille - total_du,
}

265
app/main.py Normal file
View File

@ -0,0 +1,265 @@
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)

109
app/models.py Normal file
View File

@ -0,0 +1,109 @@
"""Flat-file storage: JSON files per week for pointages, one file for congés."""
import json
from pathlib import Path
DATA_DIR = Path("/data")
POINTAGES_DIR = DATA_DIR / "pointages"
CONGES_FILE = DATA_DIR / "conges.json"
CONFIG_FILE = DATA_DIR / "config.json"
DEFAULT_CONFIG = {
"heures_jour": 7.8, # 7h48 = 7.8 h
}
def _ensure_dirs():
POINTAGES_DIR.mkdir(parents=True, exist_ok=True)
# ---------- config ----------
def load_config() -> dict:
_ensure_dirs()
if CONFIG_FILE.exists():
return json.loads(CONFIG_FILE.read_text())
CONFIG_FILE.write_text(json.dumps(DEFAULT_CONFIG, indent=2))
return DEFAULT_CONFIG.copy()
def save_config(cfg: dict):
_ensure_dirs()
CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
# ---------- pointages ----------
def _week_file(year: int, week: int) -> Path:
return POINTAGES_DIR / f"{year}-W{week:02d}.json"
def load_week_pointages(year: int, week: int) -> list[dict]:
f = _week_file(year, week)
if f.exists():
return json.loads(f.read_text())
return []
def save_week_pointages(year: int, week: int, pointages: list[dict]):
_ensure_dirs()
_week_file(year, week).write_text(json.dumps(pointages, indent=2, ensure_ascii=False))
def upsert_pointages(records: list[dict]):
"""Insert or update pointage records, grouped by ISO week."""
from datetime import datetime
by_week: dict[tuple, dict[str, dict]] = {}
for r in records:
d = datetime.strptime(r["date"], "%Y-%m-%d").date()
iso = d.isocalendar()
key = (iso.year, iso.week)
by_week.setdefault(key, {})
by_week[key][r["date"]] = r
for (year, week), new_by_date in by_week.items():
existing = {p["date"]: p for p in load_week_pointages(year, week)}
existing.update(new_by_date)
save_week_pointages(year, week, sorted(existing.values(), key=lambda p: p["date"]))
def list_all_weeks() -> list[tuple[int, int]]:
"""Return sorted list of (year, week) for which we have data."""
_ensure_dirs()
weeks = []
for f in POINTAGES_DIR.glob("????-W??.json"):
parts = f.stem.split("-W")
if len(parts) == 2:
weeks.append((int(parts[0]), int(parts[1])))
return sorted(weeks)
# ---------- congés ----------
def load_conges() -> list[dict]:
if CONGES_FILE.exists():
return json.loads(CONGES_FILE.read_text())
return []
def save_conges(conges: list[dict]):
_ensure_dirs()
CONGES_FILE.write_text(json.dumps(conges, indent=2, ensure_ascii=False))
def toggle_conge(date: str, type_conge: str):
assert type_conge in ("jour", "matin", "aprem")
conges = load_conges()
already = any(c["date"] == date and c["type"] == type_conge for c in conges)
if already:
conges = [c for c in conges if not (c["date"] == date and c["type"] == type_conge)]
else:
# Adding: remove conflicting entries first
if type_conge == "jour":
conges = [c for c in conges if c["date"] != date]
else:
conges = [c for c in conges if not (c["date"] == date and c["type"] == "jour")]
conges.append({"date": date, "type": type_conge})
conges.sort(key=lambda c: (c["date"], c["type"]))
save_conges(conges)

69
app/ods_import.py Normal file
View File

@ -0,0 +1,69 @@
"""Parse badge-reader ODS files and return structured data."""
from datetime import datetime, time
from odf.opendocument import load
from odf.table import Table, TableRow, TableCell
from odf.text import P
def _cell_text(cell) -> str:
parts = []
for p in cell.getElementsByType(P):
for node in p.childNodes:
if hasattr(node, "data"):
parts.append(node.data)
return "".join(parts).strip()
def _parse_time(val: str) -> str | None:
"""Return HH:MM or None. Ignores zero-times (formula placeholders)."""
val = val.strip()
if not val:
return None
for fmt in ("%H:%M:%S", "%H:%M"):
try:
t = datetime.strptime(val, fmt)
hhmm = t.strftime("%H:%M")
if hhmm == "00:00":
return None
return hhmm
except ValueError:
pass
return None
def _parse_date(val: str) -> str | None:
"""Return YYYY-MM-DD or None."""
val = val.strip()
for fmt in ("%d/%m/%Y", "%Y-%m-%d"):
try:
return datetime.strptime(val, fmt).strftime("%Y-%m-%d")
except ValueError:
pass
return None
def parse_ods(path: str) -> list[dict]:
"""Return list of pointage dicts from all sheets in the ODS file."""
doc = load(path)
sheets = doc.spreadsheet.getElementsByType(Table)
records = []
for sheet in sheets:
rows = sheet.getElementsByType(TableRow)
for row in rows:
cells = row.getElementsByType(TableCell)
if len(cells) < 5:
continue
date_val = _cell_text(cells[0])
date = _parse_date(date_val)
if not date:
continue
records.append({
"date": date,
"matin_entree": _parse_time(_cell_text(cells[1])),
"matin_sortie": _parse_time(_cell_text(cells[2])),
"aprem_entree": _parse_time(_cell_text(cells[3])),
"aprem_sortie": _parse_time(_cell_text(cells[4])),
})
return records

1
app/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

74
app/templates/_ligne.html Normal file
View File

@ -0,0 +1,74 @@
<tr id="row-{{ j.date }}">
<td class="col-jour">
<span class="jour-nom">{{ nom }}</span>
<span class="jour-date">{{ j.date[8:] }}/{{ j.date[5:7] }}</span>
</td>
<td class="c-matin">
<div id="cg-matin-{{ j.date }}" class="td-fill{% if 'matin' in j.conge or 'jour' in j.conge %} cg{% endif %}">
<div class="time-pair">
<input class="time-input" type="time" name="matin_entree"
data-date="{{ j.date }}" value="{{ j.matin_entree or '' }}">
<span class="time-sep"></span>
<input class="time-input" type="time" name="matin_sortie"
data-date="{{ j.date }}" value="{{ j.matin_sortie or '' }}">
</div>
</div>
</td>
<td class="c-aprem">
<div id="cg-aprem-{{ j.date }}" class="td-fill{% if 'aprem' in j.conge or 'jour' in j.conge %} cg{% endif %}">
<div class="time-pair">
<input class="time-input" type="time" name="aprem_entree"
data-date="{{ j.date }}" value="{{ j.aprem_entree or '' }}">
<span class="time-sep"></span>
<input class="time-input" type="time" name="aprem_sortie"
data-date="{{ j.date }}" value="{{ j.aprem_sortie or '' }}">
</div>
</div>
</td>
<td class="col-calc">
<span id="calc-{{ j.date }}-trav">
<span class="calc-num">{{ j.travaille if j.travaille_min else '—' }}</span>
</span>
</td>
<td class="col-calc">
<span id="calc-{{ j.date }}-delta">
<span class="{% if j.delta_min > 0 %}delta-pos{% elif j.delta_min < 0 %}delta-neg{% else %}delta-zero{% endif %}">
{%- if j.travaille_min or j.du_min -%}
{% if j.delta_min > 0 %}+{% endif %}{{ j.delta }}
{%- else %}—{%- endif %}
</span>
</span>
</td>
<td class="col-save">
<button class="btn-valider"
hx-post="/pointage/{{ j.date }}"
hx-swap="none"
hx-include="[data-date='{{ j.date }}']"
title="Enregistrer"></button>
</td>
<td class="col-conge">
<div id="cg-btn-{{ j.date }}">
<div class="conge-btns">
<button hx-post="/conge/{{ j.date }}/matin"
hx-swap="none"
class="btn-cg {% if 'matin' in j.conge %}on{% endif %}"
title="Congé matin">MA</button>
<button hx-post="/conge/{{ j.date }}/aprem"
hx-swap="none"
class="btn-cg {% if 'aprem' in j.conge %}on{% endif %}"
title="Congé après-midi">AM</button>
<button hx-post="/conge/{{ j.date }}/jour"
hx-swap="none"
class="btn-cg btn-jour {% if 'jour' in j.conge %}jour-on{% endif %}"
title="Journée entière">J</button>
</div>
</div>
</td>
</tr>

View File

@ -0,0 +1,24 @@
<div id="soldes-section" hx-swap-oob="true">
<div class="soldes">
<div class="solde-item">
<div class="solde-eyebrow">Travaillé</div>
<div class="solde-num neutral">{{ result.total_travaille }}</div>
</div>
<div class="solde-item">
<div class="solde-eyebrow">Dû cette semaine</div>
<div class="solde-num neutral">{{ result.total_du }}</div>
</div>
<div class="solde-item {% if result.solde_min > 0 %}item-pos{% elif result.solde_min < 0 %}item-neg{% endif %}">
<div class="solde-eyebrow">Solde semaine</div>
<div class="solde-num {% if result.solde_min > 0 %}pos{% elif result.solde_min < 0 %}neg{% endif %}">
{% if result.solde_min > 0 %}+{% endif %}{{ result.solde }}
</div>
</div>
<div class="solde-item {% if solde_global_min > 0 %}item-pos{% elif solde_global_min < 0 %}item-neg{% endif %}">
<div class="solde-eyebrow">Solde global</div>
<div class="solde-num {% if solde_global_min > 0 %}pos{% elif solde_global_min < 0 %}neg{% endif %}">
{% if solde_global_min > 0 %}+{% endif %}{{ solde_global }}
</div>
</div>
</div>
</div>

515
app/templates/base.html Normal file
View File

@ -0,0 +1,515 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Décompte Horaire</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet">
<script src="/static/htmx.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ground: #F4F0E8;
--surface: #FDFAF4;
--text: #1C1712;
--muted: #6B6152;
--rule: #D8D0C0;
--rule-l: #EAE5DA;
--accent: #2F5FA0;
--accent-2: #B86A08;
--pos: #2A5E42;
--pos-bg: #EBF5EE;
--neg: #9B3528;
--neg-bg: #F9EDEB;
--conge-bg: #FEF6E4;
--conge-bd: #E8C97A;
--conge-txt: #7A5300;
--nav-bg: #1C1712;
--mono: 'DM Mono', 'Cascadia Code', ui-monospace, monospace;
--sans: 'DM Sans', system-ui, -apple-system, sans-serif;
}
body {
font-family: var(--sans);
background: var(--ground);
color: var(--text);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
}
/* ── Nav ── */
nav {
background: var(--nav-bg);
color: var(--ground);
height: 50px;
padding: 0 2rem;
display: flex;
align-items: center;
gap: 2rem;
border-bottom: 3px solid var(--accent-2);
}
.nav-brand {
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
color: var(--ground);
text-decoration: none;
letter-spacing: .1em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: .55rem;
}
.nav-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--accent-2);
display: inline-block;
flex-shrink: 0;
}
nav .spacer { flex: 1; }
nav a.nav-link {
color: rgba(244,240,232,.55);
text-decoration: none;
font-size: 12px;
letter-spacing: .02em;
transition: color .15s;
}
nav a.nav-link:hover { color: var(--ground); }
/* ── Layout ── */
.container {
max-width: 940px;
margin: 0 auto;
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
/* ── Week header ── */
.week-header {
display: flex;
align-items: center;
gap: 1rem;
border-bottom: 2px solid var(--text);
padding-bottom: .75rem;
}
.week-nav-arrows { display: flex; gap: .4rem; }
.arrow-btn {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
border: 1.5px solid var(--rule);
background: transparent;
cursor: pointer;
font-size: 14px;
color: var(--muted);
text-decoration: none;
transition: border-color .15s, color .15s;
}
.arrow-btn:hover { border-color: var(--text); color: var(--text); }
.week-id {
font-family: var(--mono);
font-size: 1.75rem;
font-weight: 500;
letter-spacing: -.02em;
line-height: 1;
}
.week-id .wk { color: var(--muted); font-size: 1rem; font-weight: 300; }
.week-id .slash { color: var(--rule); margin: 0 .2em; }
/* ── Soldes ── */
.soldes {
display: grid;
grid-template-columns: repeat(4, 1fr);
border: 1.5px solid var(--rule);
background: var(--surface);
}
@media (max-width: 600px) { .soldes { grid-template-columns: 1fr 1fr; } }
.solde-item {
padding: 1rem 1.25rem;
border-right: 1px solid var(--rule);
}
.solde-item:last-child { border-right: none; }
.solde-item.item-pos { background: var(--pos-bg); }
.solde-item.item-neg { background: var(--neg-bg); }
.solde-eyebrow {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: .45rem;
}
.solde-num {
font-family: var(--mono);
font-size: 1.9rem;
font-weight: 500;
letter-spacing: -.03em;
line-height: 1;
}
.solde-num.pos { color: var(--pos); }
.solde-num.neg { color: var(--neg); }
.solde-num.neutral { color: var(--text); }
/* ── Table ── */
.table-section { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
border: 1.5px solid var(--rule);
background: var(--surface);
}
.th-group th {
padding: .45rem .85rem .25rem;
font-family: var(--mono);
font-size: 9px;
letter-spacing: .12em;
text-transform: uppercase;
font-weight: 500;
color: var(--muted);
border-bottom: 1px solid var(--rule-l);
background: var(--ground);
white-space: nowrap;
}
.th-group .g-matin {
background: rgba(47,95,160,.07);
color: var(--accent);
border-left: 3px solid var(--accent);
text-align: center;
}
.th-group .g-aprem {
background: rgba(184,106,8,.07);
color: var(--accent-2);
border-left: 3px solid var(--accent-2);
text-align: center;
}
.th-group .g-calc { text-align: right; }
.th-group .g-conge { text-align: center; }
.th-sub th {
padding: .25rem .85rem .5rem;
font-size: 10px;
color: rgba(107,97,82,.65);
border-bottom: 2px solid var(--text);
font-weight: 400;
background: var(--ground);
white-space: nowrap;
}
.th-sub .s-matin {
background: rgba(47,95,160,.04);
border-left: 3px solid var(--accent);
color: rgba(47,95,160,.75);
text-align: center;
font-family: var(--mono);
font-size: 9px;
letter-spacing: .04em;
}
.th-sub .s-aprem {
background: rgba(184,106,8,.04);
border-left: 3px solid var(--accent-2);
color: rgba(184,106,8,.85);
text-align: center;
font-family: var(--mono);
font-size: 9px;
letter-spacing: .04em;
}
.th-sub .s-calc { text-align: right; }
.th-sub .s-conge { text-align: center; }
tbody tr { transition: background .1s; }
tbody tr:hover td { background: rgba(47,95,160,.025); }
tbody tr:hover .td-fill { background: rgba(47,95,160,.025); }
tbody tr:hover .td-fill.cg { background: var(--conge-bg); }
tbody tr:not(:last-child) td { border-bottom: 1px solid var(--rule-l); }
td {
padding: .6rem .85rem;
vertical-align: middle;
}
/* Matin / aprem cells: padding moved to inner .td-fill so OOB can target it */
td.c-matin, td.c-aprem { padding: 0; }
.td-fill {
display: flex;
align-items: center;
padding: .6rem .85rem;
}
td.c-matin .td-fill { border-left: 3px solid rgba(47,95,160,.2); }
td.c-aprem .td-fill { border-left: 3px solid rgba(184,106,8,.2); }
/* Congé state — applied to the inner div, not the <td> */
.td-fill.cg {
background: var(--conge-bg);
}
td.c-matin .td-fill.cg,
td.c-aprem .td-fill.cg { border-left-color: var(--conge-bd); }
.td-fill.cg .time-input { background: var(--conge-bg); }
.col-jour { white-space: nowrap; }
.jour-nom { display: block; font-weight: 500; font-size: 13px; }
.jour-date { display: block; font-family: var(--mono); font-size: 11px; color: var(--muted); }
.time-pair { display: flex; align-items: center; gap: 4px; justify-content: center; }
.time-sep { color: var(--rule); font-size: 11px; flex-shrink: 0; }
.time-input {
width: 80px;
padding: .28rem .4rem;
border: 1px solid var(--rule);
border-radius: 0;
font-family: var(--mono);
font-size: 12px;
color: var(--text);
background: transparent;
text-align: center;
transition: border-color .15s;
}
.time-input:hover { border-color: var(--muted); }
.time-input:focus { outline: none; border-color: var(--accent); background: #fff; }
tr.htmx-request .time-input { opacity: .4; }
.col-calc { text-align: right; white-space: nowrap; width: 72px; }
.calc-num { font-family: var(--mono); font-size: 13px; color: var(--muted); }
.delta-pos { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--pos); }
.delta-neg { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--neg); }
.delta-zero{ font-family: var(--mono); font-size: 13px; color: var(--rule); }
.col-save { width: 36px; text-align: center; padding: .4rem .3rem; }
.g-save { width: 36px; background: var(--ground); }
.btn-valider {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px; height: 26px;
border: 1.5px solid var(--accent);
background: transparent;
cursor: pointer;
font-size: 13px;
color: var(--accent);
transition: background .15s, color .15s;
}
.btn-valider:hover { background: var(--accent); color: #fff; }
.btn-valider.htmx-request { opacity: .4; pointer-events: none; }
.col-conge { width: 88px; text-align: center; }
.conge-btns { display: flex; gap: 3px; justify-content: center; }
.btn-cg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px; height: 24px;
border: 1.5px solid var(--rule);
background: transparent;
cursor: pointer;
font-family: var(--mono);
font-size: 9px;
font-weight: 500;
letter-spacing: .04em;
color: var(--muted);
transition: all .15s;
}
.btn-cg:hover { border-color: var(--conge-txt); color: var(--conge-txt); background: var(--conge-bg); }
.btn-cg.on { background: var(--conge-bg); border-color: var(--conge-bd); color: var(--conge-txt); }
.btn-cg.btn-jour { border-radius: 50%; width: 22px; height: 22px; font-size: 9px; }
.btn-cg.jour-on { background: var(--accent-2); border-color: #955505; color: #fff; }
/* ── Historique ── */
.history {
display: flex;
flex-direction: column;
gap: .75rem;
border-top: 1px solid var(--rule);
padding-top: 1.25rem;
}
.hist-label {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
}
.hist-body { display: flex; flex-direction: column; gap: .6rem; }
.hist-year-group { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.hist-year {
font-family: var(--mono);
font-size: 11px;
font-weight: 500;
color: var(--muted);
width: 36px;
flex-shrink: 0;
}
.hist-chips { display: flex; flex-wrap: wrap; gap: .3rem; }
.chip {
font-family: var(--mono);
font-size: 11px;
padding: .18rem .5rem;
border: 1px solid var(--rule);
color: var(--muted);
text-decoration: none;
letter-spacing: .02em;
transition: all .15s;
}
.chip:hover { border-color: var(--text); color: var(--text); }
.chip.here { background: var(--text); border-color: var(--text); color: var(--ground); }
/* ── Import ── */
.import-wrap { padding: 2rem; }
.import-wrap h2 { font-size: 1rem; font-weight: 600; margin-bottom: .75rem; border-bottom: 1px solid var(--rule); padding-bottom: .5rem; }
.import-desc { color: var(--muted); font-size: 13px; margin-bottom: 1.25rem; line-height: 1.65; }
.file-input { display: block; margin-bottom: 1rem; font-size: 13px; }
.btn-submit {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .5rem 1.1rem;
border: 1.5px solid var(--text);
background: var(--text);
color: var(--ground);
font-size: 12px;
font-family: var(--mono);
font-weight: 500;
letter-spacing: .05em;
text-transform: uppercase;
cursor: pointer;
transition: background .15s;
}
.btn-submit:hover { background: #33271E; border-color: #33271E; }
.msg-ok { padding: .6rem 1rem; border-left: 3px solid var(--pos); background: var(--pos-bg); color: var(--pos); font-size: 13px; margin-bottom: 1rem; }
.msg-err { padding: .6rem 1rem; border-left: 3px solid var(--neg); background: var(--neg-bg); color: var(--neg); font-size: 13px; margin-bottom: 1rem; }
/* ── Card shell (import page) ── */
.card { background: var(--surface); border: 1.5px solid var(--rule); }
/* ── Calendrier sélecteur ── */
.cal-trigger-wrap { position: relative; }
.cal-trigger {
display: flex;
align-items: baseline;
gap: .1em;
background: none;
border: none;
cursor: pointer;
font-family: var(--mono);
font-size: 1.75rem;
font-weight: 500;
letter-spacing: -.02em;
color: var(--text);
padding: 0;
line-height: 1;
}
.cal-trigger .wk { color: var(--muted); font-size: 1rem; font-weight: 300; }
.cal-trigger .slash { color: var(--rule); margin: 0 .2em; }
.cal-trigger .cal-caret {
font-size: .85rem;
color: var(--muted);
margin-left: .35em;
transition: transform .15s;
}
.cal-trigger[aria-expanded="true"] .cal-caret { transform: rotate(180deg); }
.cal-trigger:hover .cal-caret { color: var(--text); }
.cal-popup {
position: absolute;
top: calc(100% + .5rem);
left: 0;
z-index: 100;
background: var(--surface);
border: 1.5px solid var(--rule);
box-shadow: 0 4px 20px rgba(28,23,18,.12);
padding: .75rem;
min-width: 260px;
}
.cal-popup[hidden] { display: none; }
.cal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: .6rem;
}
.cal-month-label {
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--text);
}
.cal-nav {
background: none;
border: 1px solid var(--rule);
width: 24px; height: 24px;
cursor: pointer;
font-size: 12px;
color: var(--muted);
display: flex; align-items: center; justify-content: center;
transition: border-color .15s, color .15s;
}
.cal-nav:hover { border-color: var(--text); color: var(--text); }
.cal-grid { width: 100%; border-collapse: collapse; }
.cal-grid thead th {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--muted);
padding: .2rem .3rem;
text-align: center;
border-bottom: 1px solid var(--rule);
}
.cal-grid thead th.cal-wn {
color: var(--accent);
border-right: 1px solid var(--rule-l);
}
.cal-grid thead th.cal-we { color: var(--neg); }
.cal-grid .cal-row td {
font-family: var(--mono);
font-size: 12px;
text-align: center;
padding: .3rem .25rem;
color: var(--text);
transition: background .1s;
}
.cal-grid .cal-row:hover td { background: var(--ground); }
.cal-grid .cal-row:hover td.cal-wn { background: rgba(47,95,160,.1); }
.cal-grid .cal-row td.cal-wn {
color: var(--accent);
font-weight: 500;
font-size: 11px;
border-right: 1px solid var(--rule-l);
padding-right: .4rem;
}
.cal-grid .cal-row td.cal-out { color: var(--rule); }
.cal-grid .cal-row td.cal-we { color: var(--muted); }
.cal-grid .cal-current td {
background: var(--ground);
font-weight: 500;
}
.cal-grid .cal-current td.cal-wn {
background: var(--accent);
color: #fff;
}
.cal-grid .cal-current:hover td { background: rgba(47,95,160,.06); }
.cal-grid .cal-current:hover td.cal-wn { background: var(--accent); }
</style>
</head>
<body>
<nav>
<a href="/" class="nav-brand"><span class="nav-dot"></span>Décompte Horaire</a>
<span class="spacer"></span>
<a href="/" class="nav-link">Semaine courante</a>
<a href="/import" class="nav-link">Importer ODS</a>
</nav>
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>

23
app/templates/import.html Normal file
View File

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<div class="card import-wrap">
<h2>Importer un fichier ODS</h2>
{% if msg %}
{% if 'Erreur' in msg %}
<div class="msg-err">{{ msg }}</div>
{% else %}
<div class="msg-ok">✓ {{ msg }}</div>
{% endif %}
{% endif %}
<p class="import-desc">
Sélectionne le fichier exporté par la badgeuse.<br>
Les données existantes sont mises à jour — les congés ne sont pas affectés.
</p>
<form method="post" enctype="multipart/form-data">
<input class="file-input" type="file" name="fichier" accept=".ods" required>
<button type="submit" class="btn-submit">Importer</button>
</form>
</div>
{% endblock %}

205
app/templates/semaine.html Normal file
View File

@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block content %}
<div class="week-header">
<div class="week-nav-arrows">
<a href="/semaine/{{ prev_year }}/{{ prev_week }}" class="arrow-btn">&#8592;</a>
<a href="/semaine/{{ next_year }}/{{ next_week }}" class="arrow-btn">&#8594;</a>
</div>
<!-- Sélecteur calendrier -->
<div class="cal-trigger-wrap">
<button class="cal-trigger" id="cal-trigger" aria-expanded="false"
onclick="toggleCal()">
<span class="wk">S</span>{{ week }}<span class="slash">/</span>{{ year }}
<span class="cal-caret"></span>
</button>
<div class="cal-popup" id="cal-popup" hidden>
<div class="cal-header">
<button class="cal-nav" onclick="calPrev()">&#8592;</button>
<span class="cal-month-label" id="cal-month-label"></span>
<button class="cal-nav" onclick="calNext()">&#8594;</button>
</div>
<table class="cal-grid">
<thead>
<tr>
<th class="cal-wn">S</th>
<th>L</th><th>M</th><th>M</th><th>J</th><th>V</th>
<th class="cal-we">S</th><th class="cal-we">D</th>
</tr>
</thead>
<tbody id="cal-body"></tbody>
</table>
</div>
</div>
</div>
<!-- Soldes -->
{% include "_soldes.html" %}
<!-- Tableau -->
<div class="table-section">
<table>
<thead>
<tr class="th-group">
<th class="g-jour" rowspan="2">Jour</th>
<th class="g-matin">Matin</th>
<th class="g-aprem">Après-midi</th>
<th class="g-calc" rowspan="2">Travaillé</th>
<th class="g-calc" rowspan="2">Delta</th>
<th class="g-save" rowspan="2"></th>
<th class="g-conge" rowspan="2">Congé</th>
</tr>
<tr class="th-sub">
<th class="s-matin">entrée &rarr; sortie</th>
<th class="s-aprem">entrée &rarr; sortie</th>
</tr>
</thead>
<tbody>
{% for j, nom in result.jours | zip(jours_fr) %}
{% include "_ligne.html" %}
{% endfor %}
</tbody>
</table>
</div>
<!-- Historique groupé par année -->
{% if weeks_by_year %}
<div class="history">
<span class="hist-label">Historique</span>
<div class="hist-body">
{% for yr, wks in weeks_by_year.items() %}
<div class="hist-year-group">
<span class="hist-year">{{ yr }}</span>
<div class="hist-chips">
{% for yw in wks %}
<a href="/semaine/{{ yw[0] }}/{{ yw[1] }}"
class="chip{% if yw[0] == year and yw[1] == week %} here{% endif %}">
S{{ '%02d' % yw[1] }}
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<script>
(function () {
const CURRENT_YEAR = {{ year }};
const CURRENT_WEEK = {{ week }};
// ISO week number from a Date
function isoWeek(d) {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
// Thursday of this week determines the ISO year
date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return {
week: Math.ceil((((date - yearStart) / 86400000) + 1) / 7),
year: date.getUTCFullYear()
};
}
// Monday of an ISO week
function mondayOfISOWeek(year, week) {
const simple = new Date(Date.UTC(year, 0, 1 + (week - 1) * 7));
const dow = simple.getUTCDay() || 7; // Mon=1..Sun=7
if (dow <= 4) simple.setUTCDate(simple.getUTCDate() - dow + 1);
else simple.setUTCDate(simple.getUTCDate() + 8 - dow);
return simple;
}
const MONTHS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin',
'Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
// Start calendar on the month containing the current week's Monday
const initMonday = mondayOfISOWeek(CURRENT_YEAR, CURRENT_WEEK);
let calYear = initMonday.getUTCFullYear();
let calMonth = initMonday.getUTCMonth(); // 0-based
function renderCal() {
document.getElementById('cal-month-label').textContent =
MONTHS_FR[calMonth] + ' ' + calYear;
const body = document.getElementById('cal-body');
body.innerHTML = '';
// First day of month (Mon-based grid)
const first = new Date(Date.UTC(calYear, calMonth, 1));
const startDow = (first.getUTCDay() || 7) - 1; // 0=Mon
const startDate = new Date(first);
startDate.setUTCDate(1 - startDow);
// 6 rows × 7 days
for (let row = 0; row < 6; row++) {
const monday = new Date(startDate);
monday.setUTCDate(startDate.getUTCDate() + row * 7);
// Skip row if entirely outside ±1 month
const firstOfRow = monday;
const lastOfRow = new Date(monday); lastOfRow.setUTCDate(monday.getUTCDate() + 6);
if (firstOfRow.getUTCMonth() > calMonth + 1 ||
lastOfRow.getUTCMonth() < calMonth - 1) continue;
const { week: wn, year: wy } = isoWeek(monday);
const isCurrent = (wy === CURRENT_YEAR && wn === CURRENT_WEEK);
const tr = document.createElement('tr');
tr.className = 'cal-row' + (isCurrent ? ' cal-current' : '');
tr.style.cursor = 'pointer';
tr.title = `Semaine ${wn} / ${wy}`;
tr.onclick = () => { window.location = `/semaine/${wy}/${wn}`; };
// Week number cell
const wnTd = document.createElement('td');
wnTd.className = 'cal-wn';
wnTd.textContent = wn;
tr.appendChild(wnTd);
for (let d = 0; d < 7; d++) {
const day = new Date(monday); day.setUTCDate(monday.getUTCDate() + d);
const td = document.createElement('td');
td.textContent = day.getUTCDate();
const inMonth = day.getUTCMonth() === calMonth;
if (!inMonth) td.className = 'cal-out';
if (d >= 5) td.className = (td.className ? td.className + ' ' : '') + 'cal-we';
tr.appendChild(td);
}
body.appendChild(tr);
}
}
window.calPrev = () => {
calMonth--;
if (calMonth < 0) { calMonth = 11; calYear--; }
renderCal();
};
window.calNext = () => {
calMonth++;
if (calMonth > 11) { calMonth = 0; calYear++; }
renderCal();
};
window.toggleCal = () => {
const popup = document.getElementById('cal-popup');
const btn = document.getElementById('cal-trigger');
const open = popup.hidden;
popup.hidden = !open;
btn.setAttribute('aria-expanded', String(open));
if (open) renderCal();
};
// Close on outside click
document.addEventListener('click', e => {
const wrap = document.querySelector('.cal-trigger-wrap');
if (wrap && !wrap.contains(e.target)) {
document.getElementById('cal-popup').hidden = true;
document.getElementById('cal-trigger').setAttribute('aria-expanded', 'false');
}
});
})();
</script>
{% endblock %}

8
compose.yml Normal file
View File

@ -0,0 +1,8 @@
services:
app:
build: .
ports:
- "8000:8000"
volumes:
- ./data:/data
restart: unless-stopped

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
jinja2==3.1.4
python-multipart==0.0.9
odfpy==1.4.1