From 6258fd3662c90b844ac369afeef9fa5b16afab72 Mon Sep 17 00:00:00 2001 From: toto Date: Wed, 24 Jun 2026 15:35:17 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20colonnes=20=CE=94=20sem.=20/=20Fin=20c?= =?UTF-8?q?ible,=20supprime=20solde=20global,=20indicateurs=20minima?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouvelles colonnes : Δ sem. (solde cumulé semaine) et Fin cible (heure de sortie aprem optimale pour 0h suppl. en fin de semaine) - Δ sem. n'est affiché que quand la journée est complète et la chaîne de jours ininterrompue (pas de saut de jour non saisi) - Fin cible disparaît quand la journée est validée (passée/complète) - Delta = "—" pour les jours non travaillés (futur ou congé jour) - Indicateur warn (fond rouge) sur matin/aprem si horaires < minimum (matin 09:00→12:00, aprem 14:00→17:00) ; mise à jour live via JS - Suppression du solde global (3 cartes au lieu de 4) - Tableau sans largeurs fixes + container 1100 px pour éviter scroll horizontal sur écrans normaux ; touch-scroll conservé sur mobile - Base reste 39h/semaine (7h48/jour) Co-Authored-By: Claude Sonnet 4.6 --- app/calcul.py | 45 ++++++++++++++++ app/main.py | 104 +++++++++++++++++++++++++------------ app/models.py | 2 +- app/templates/_ligne.html | 40 ++++++++++++-- app/templates/_soldes.html | 6 --- app/templates/base.html | 29 ++++++++--- app/templates/semaine.html | 25 ++++++++- 7 files changed, 197 insertions(+), 54 deletions(-) diff --git a/app/calcul.py b/app/calcul.py index 7f6f967..01b8ff8 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -71,6 +71,51 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "conge": {c["type"] for c in conges if c["date"] == p["date"]}, }) + # Mark each day as complete (all non-congé slots filled, or day is a congé/weekend) + for jour in jours: + cg = jour["conge"] + matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg + aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg + jour["is_complete"] = (jour["du_min"] == 0) or (matin_done and aprem_done) + + # Cumulative weekly delta — accumulated for all days, but visible only when chain is unbroken + cumul = 0 + cumul_chain_ok = True + for jour in jours: + cumul += jour["delta_min"] + jour["delta_cumul_min"] = cumul + jour["delta_cumul"] = minutes_to_hhmm(abs(cumul)) + # Chain breaks on the first incomplete workday — subsequent days don't show cumul + if not jour["is_complete"] and jour["du_min"] > 0: + cumul_chain_ok = False + jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok + + # Sortie cible: optimal aprem exit so the week totals exactly the target hours + worked_before = 0 + for i, jour in enumerate(jours): + cg = jour["conge"] + if jour["du_min"] == 0 or "aprem" in cg or "jour" in cg: + jour["sortie_cible"] = None + worked_before += jour["travaille_min"] + continue + + du_remaining = sum(j["du_min"] for j in jours[i + 1:]) + needed_today = max(0, total_du - worked_before - du_remaining) + + worked_morning = 0 + if jour.get("matin_entree") and jour.get("matin_sortie"): + worked_morning = max(0, hhmm_to_minutes(jour["matin_sortie"]) - hhmm_to_minutes(jour["matin_entree"])) + + needed_aprem = max(0, needed_today - worked_morning) + aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else 14 * 60 + sortie_min = max(aprem_start + needed_aprem, 17 * 60) + + if sortie_min > 23 * 60 + 59: + jour["sortie_cible"] = None # deficit too large to recover in one afternoon + else: + jour["sortie_cible"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" + worked_before += jour["travaille_min"] + return { "jours": jours, "total_travaille": minutes_to_hhmm(total_travaille), diff --git a/app/main.py b/app/main.py index 5a17fdf..f66f4d6 100644 --- a/app/main.py +++ b/app/main.py @@ -43,7 +43,7 @@ def _weeks_by_year(all_weeks: list) -> dict: def _build_soldes_ctx(year: int, week: int, conges: list[dict], h_jour: int) -> dict: - """Compute week result + global solde for the soldes partial.""" + """Compute week result for the soldes partial.""" dates = week_dates(year, week) raw = load_week_pointages(year, week) p_by_date = {p["date"]: p for p in raw} @@ -53,21 +53,7 @@ def _build_soldes_ctx(year: int, week: int, conges: list[dict], h_jour: int) -> 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, - } + return {"result": result} def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict: @@ -95,8 +81,8 @@ def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict: def _oob_calc(date: str, jour: dict) -> str: """Build OOB fragments for the two calc cells (safe non-table elements).""" trav_inner = f'{"—" if not jour["travaille_min"] else jour["travaille"]}' - 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"]: + if jour["travaille_min"]: + delta_cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero") sign = "+" if jour["delta_min"] > 0 else "" delta_inner = f'{sign}{jour["delta"]}' else: @@ -107,20 +93,47 @@ def _oob_calc(date: str, jour: dict) -> str: ) -def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse: - """Return OOB
/ fragments for congé state + calc + soldes. No table elements.""" - iso = d.isocalendar() - jour = _build_jour_ctx(date, conges, h_jour) +def _oob_week_extras(result: dict) -> str: + """OOB for all 5 days' cumulative delta and sortie cible.""" + html = "" + for jour in result["jours"]: + date = jour["date"] + # Cumulative delta — only when chain of complete days is unbroken + cm = jour["delta_cumul_min"] + if jour.get("cumul_visible"): + if cm == 0: + cumul_inner = '0h00' + else: + cls = "delta-pos" if cm > 0 else "delta-neg" + sign = "+" if cm > 0 else "-" + cumul_inner = f'{sign}{jour["delta_cumul"]}' + else: + cumul_inner = '' + html += f'{cumul_inner}' + # Sortie cible — only for incomplete days + cible = jour.get("sortie_cible") if not jour.get("is_complete") else None + cible_inner = f'{cible}' if cible else '' + html += f'{cible_inner}' + return html + + +def _oob_time_cells(date: str, jour: dict) -> str: + """OOB
for matin/aprem cells with congé state and warn class.""" 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_cg = "matin" in cg or "jour" in cg + aprem_cg = "aprem" in cg or "jour" in cg + matin_warn = not matin_cg and jour["du_min"] > 0 and ( + (jour.get("matin_entree") and jour["matin_entree"] > "09:00") or + (jour.get("matin_sortie") and jour["matin_sortie"] < "12:00") + ) + aprem_warn = not aprem_cg and jour["du_min"] > 0 and ( + (jour.get("aprem_entree") and jour["aprem_entree"] > "14:00") or + (jour.get("aprem_sortie") and jour["aprem_sortie"] < "17:00") + ) + matin_cls = "td-fill cg" if matin_cg else ("td-fill warn" if matin_warn else "td-fill") + aprem_cls = "td-fill cg" if aprem_cg else ("td-fill warn" if aprem_warn else "td-fill") matin_html = ( - f'
' + f'
' f'
' f'' f'' @@ -128,13 +141,25 @@ def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h f'
' ) aprem_html = ( - f'
' + f'
' f'
' f'' f'' f'' f'
' ) + return matin_html + aprem_html + + +def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse: + """Return OOB
/ fragments for congé state + calc + soldes. No table elements.""" + iso = d.isocalendar() + jour = _build_jour_ctx(date, conges, h_jour) + cg = jour["conge"] + + btn_ma_on = " on" if "matin" in cg else "" + btn_am_on = " on" if "aprem" in cg else "" + btn_j_on = " jour-on" if "jour" in cg else "" btns_html = ( f'
' f'
' @@ -146,16 +171,27 @@ def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h 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) + return HTMLResponse( + _oob_time_cells(date, jour) + + btns_html + + _oob_calc(date, jour) + + _oob_week_extras(soldes_ctx["result"]) + + soldes_html + ) def _htmx_calc_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse: - """Return OOB for calc cells + OOB
for soldes. No table elements.""" + """Return OOB /
for calc cells, time cells, week extras, and soldes.""" iso = d.isocalendar() jour = _build_jour_ctx(date, conges, h_jour) soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx}) - return HTMLResponse(_oob_calc(date, jour) + soldes_html) + return HTMLResponse( + _oob_calc(date, jour) + + _oob_time_cells(date, jour) + + _oob_week_extras(soldes_ctx["result"]) + + soldes_html + ) @app.get("/", response_class=HTMLResponse) diff --git a/app/models.py b/app/models.py index 5a91fcd..aa462bb 100644 --- a/app/models.py +++ b/app/models.py @@ -8,7 +8,7 @@ CONGES_FILE = DATA_DIR / "conges.json" CONFIG_FILE = DATA_DIR / "config.json" DEFAULT_CONFIG = { - "heures_jour": 7.8, # 7h48 = 7.8 h + "heures_jour": 7.8, # 7h48 = 39h/semaine } diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index 61e5111..40b102c 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -1,3 +1,13 @@ +{% set matin_cg = 'matin' in j.conge or 'jour' in j.conge %} +{% set aprem_cg = 'aprem' in j.conge or 'jour' in j.conge %} +{% set matin_warn = not matin_cg and j.du_min > 0 and ( + (j.matin_entree and j.matin_entree > '09:00') or + (j.matin_sortie and j.matin_sortie < '12:00') +) %} +{% set aprem_warn = not aprem_cg and j.du_min > 0 and ( + (j.aprem_entree and j.aprem_entree > '14:00') or + (j.aprem_sortie and j.aprem_sortie < '17:00') +) %} @@ -6,7 +16,7 @@ -
+
@@ -18,7 +28,7 @@ -
+
@@ -37,14 +47,36 @@ - - {%- if j.travaille_min or j.du_min -%} + + {%- if j.travaille_min -%} {% if j.delta_min > 0 %}+{% endif %}{{ j.delta }} {%- else %}—{%- endif %} + + + {%- if j.cumul_visible -%} + + {% if j.delta_cumul_min > 0 %}+{% elif j.delta_cumul_min < 0 %}-{% endif %}{{ j.delta_cumul if j.delta_cumul_min != 0 else '0h00' }} + + {%- else -%} + + {%- endif %} + + + + + + {%- if j.sortie_cible and not j.is_complete -%} + {{ j.sortie_cible }} + {%- else -%} + + {%- endif %} + + +
-
-
Solde global
-
- {% if solde_global_min > 0 %}+{% endif %}{{ solde_global }} -
-
diff --git a/app/templates/base.html b/app/templates/base.html index c8f4cc4..9a61c48 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -82,7 +82,7 @@ /* ── Layout ── */ .container { - max-width: 940px; + max-width: 1100px; margin: 0 auto; padding: 2rem 1.5rem; display: flex; @@ -124,7 +124,7 @@ /* ── Soldes ── */ .soldes { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); border: 1.5px solid var(--rule); background: var(--surface); } @@ -156,12 +156,12 @@ .solde-num.neutral { color: var(--text); } /* ── Table ── */ - .table-section { overflow-x: auto; } + .table-section { overflow-x: auto; -webkit-overflow-scrolling: touch; } table { - width: 100%; border-collapse: collapse; border: 1.5px solid var(--rule); background: var(--surface); + white-space: nowrap; } .th-group th { @@ -272,14 +272,27 @@ .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; } + .col-calc { text-align: right; padding-left: .5rem; } .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); } + /* Warn state: time input violates minimum directive */ + td.c-matin .td-fill.warn, + td.c-aprem .td-fill.warn { border-left-color: var(--neg); background: var(--neg-bg); } + .td-fill.warn .time-input { background: transparent; } + tbody tr:hover .td-fill.warn { background: var(--neg-bg); } + + /* Cumulative delta and sortie cible columns */ + .col-cumul { text-align: right; padding-left: .5rem; } + .col-cible { text-align: right; padding-left: .5rem; } + .g-cumul { text-align: right; } + .g-cible { text-align: right; } + .cible-num { font-family: var(--mono); font-size: 12px; color: var(--accent); font-weight: 500; } + + .col-save { text-align: center; padding: .4rem .3rem; } + .g-save { background: var(--ground); } .btn-valider { display: inline-flex; align-items: center; @@ -294,7 +307,7 @@ } .btn-valider:hover { background: var(--accent); color: #fff; } .btn-valider.htmx-request { opacity: .4; pointer-events: none; } - .col-conge { width: 88px; text-align: center; } + .col-conge { text-align: center; } .conge-btns { display: flex; gap: 3px; justify-content: center; } .btn-cg { display: inline-flex; diff --git a/app/templates/semaine.html b/app/templates/semaine.html index 424725a..48c847c 100644 --- a/app/templates/semaine.html +++ b/app/templates/semaine.html @@ -46,7 +46,9 @@ Matin Après-midi Travaillé - Delta + Δ jour + Δ sem. + Fin cible Congé @@ -202,4 +204,25 @@ })(); + {% endblock %}