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:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
data/
|
||||
*.ods
|
||||
.~lock.*
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal 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
80
app/calcul.py
Normal 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
265
app/main.py
Normal 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
109
app/models.py
Normal 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
69
app/ods_import.py
Normal 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
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
74
app/templates/_ligne.html
Normal 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>
|
||||
24
app/templates/_soldes.html
Normal file
24
app/templates/_soldes.html
Normal 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
515
app/templates/base.html
Normal 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
23
app/templates/import.html
Normal 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
205
app/templates/semaine.html
Normal 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">←</a>
|
||||
<a href="/semaine/{{ next_year }}/{{ next_week }}" class="arrow-btn">→</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()">←</button>
|
||||
<span class="cal-month-label" id="cal-month-label"></span>
|
||||
<button class="cal-nav" onclick="calNext()">→</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 → sortie</th>
|
||||
<th class="s-aprem">entrée → 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
8
compose.yml
Normal file
@ -0,0 +1,8 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user