diff --git a/README.md b/README.md index 99c75cf..b4a5e4d 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,37 @@ demi-journées restantes. --- +## Astreinte & repos de 11h + +Le bouton **AS** marque un jour d'astreinte. Ce jour et son lendemain (la nuit qui +suit), une ligne de saisie supplémentaire apparaît sous l'après-midi : + +- **Fin activité** : l'heure de fin de la **dernière activité** hors horaires de + travail. Elle peut tomber le matin (intervention ayant dépassé minuit) : saisie + sur le jour d'astreinte, une fin matinale (ex: 06:00) désigne la fin de sa nuit ; + saisie sur le lendemain, une fin matinale désigne la nuit de la veille. + +L'app en déduit l'heure de retour au bureau : **fin + 11h de repos consécutif** +(exigées par période de 24h), affichée sous le champ (« retour ≥ 13:00 »). Fin +23:30 → retour 10:30 le lendemain ; fin 02:00 → retour 13:00 ; fin 06:00 → retour +17:00. Un repos qui se termine avant l'ouverture ne contraint rien. + +À partir de cette heure de retour, tout ce qui la précède sur la journée quitte le +décompte de la semaine : + +- la présence obligatoire avant l'heure de retour est retirée des heures dues + (ex: retour 13:00 → la plage 09:00-12:00 ne compte plus) ; +- une présence pointée avant l'heure de retour ne compte pas dans le travaillé ; +- les préconisations **Fin cible** démarrent à l'heure de retour (arrivée du matin + décalée d'autant, sans avertissement de retard) ; +- une demi-journée entièrement couverte par le repos est considérée comme remplie. + +La fin d'activité ne s'applique que les jours qualifiés (astreinte ou lendemain) : +partout ailleurs elle est ignorée. Les colonnes **Astreinte** (fin saisie) et +**Retour** (calculé) apparaissent dans les exports `.ods`. + +--- + ## Stack `FastAPI` · `HTMX` · `Jinja2` · fichiers JSON · `Docker` diff --git a/app/calcul.py b/app/calcul.py index 1454d0b..7ffd541 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -1,5 +1,5 @@ """Business logic: compute worked hours and expected hours per day/week.""" -from datetime import datetime +from datetime import datetime, timedelta DEFAULT_PLAGES = { "matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin @@ -11,6 +11,8 @@ DEFAULT_PLAGES = { "depart_vise": "", # départ du soir visé, plafond souple des cibles aprem (vide = aucun) } +REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau + def hhmm_to_minutes(val: str | None) -> int: if not val: @@ -25,32 +27,132 @@ def minutes_to_hhmm(minutes: int) -> str: return f"{sign}{minutes // 60}h{minutes % 60:02d}" -def heures_travaillees(p: dict) -> int: +def minutes_to_time(minutes: int) -> str: + return f"{minutes // 60:02d}:{minutes % 60:02d}" + + +def _veille(date_str: str) -> str: + d = datetime.strptime(date_str, "%Y-%m-%d").date() + return (d - timedelta(days=1)).strftime("%Y-%m-%d") + + +def jour_astreinte_qualifie(date_str: str, astreintes) -> bool: + """True si le jour permet la saisie d'opérations d'astreinte : jour d'astreinte, + ou lendemain d'un jour d'astreinte (la nuit qui suit l'astreinte).""" + astreintes = set(astreintes or []) + return date_str in astreintes or _veille(date_str) in astreintes + + +def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) -> tuple[int, int]: + """Présence obligatoire (matin, aprem) tombant avant l'heure de retour au bureau, + en minutes : c'est le temps de présence retiré du décompte de la semaine quand le + repos de 11h décale l'arrivée. Les demi-journées en congé ne sont pas décomptées + deux fois.""" + if retour_min is None: + return 0, 0 + matin_debut = hhmm_to_minutes(plages["matin_debut"]) + matin_fin = hhmm_to_minutes(plages["matin_fin"]) + aprem_debut = hhmm_to_minutes(plages["aprem_debut"]) + aprem_fin = hhmm_to_minutes(plages["aprem_fin"]) + matin_ex = 0 if ("matin" in conges_date or "jour" in conges_date) else max(0, min(matin_fin, retour_min) - matin_debut) + aprem_ex = 0 if ("aprem" in conges_date or "jour" in conges_date) else max(0, min(aprem_fin, retour_min) - aprem_debut) + return matin_ex, aprem_ex + + +def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int) -> tuple[int, int]: + """Nominaux (attendus) matin et aprem en minutes, après congés et exclusion liée + à l'heure de retour.""" + if "jour" in conges_date: + m = a = 0 + elif "matin" in conges_date: + m, a = 0, du_full_min + elif "aprem" in conges_date: + m, a = du_full_min, 0 + else: + m, a = du_full_min // 2, du_full_min - du_full_min // 2 + return max(0, m - matin_excl_min), max(0, a - aprem_excl_min) + + +def retour_apres_repos(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None: + """Heure de retour au bureau après les 11h de repos consécutif qui suivent la + dernière activité d'astreinte. + + Seule l'heure de FIN de cette activité est saisie, sur le jour d'astreinte ou + son lendemain. La fin peut tomber le matin (l'intervention a dépassé minuit) : + saisie sur le jour d'astreinte, une fin matinale désigne la fin de SA nuit ; + saisie sur le lendemain, une fin matinale désigne la fin de la nuit de la + veille. Le retour s'applique au jour où se termine le repos : fin 23:30 → + retour 10:30 le lendemain ; fin 06:00 → retour 17:00. Renvoie des minutes + depuis minuit, ou None si rien ne contraint le retour au-delà de l'ouverture.""" + astreintes = set(astreintes or []) + matin_debut = hhmm_to_minutes(plages["matin_debut"]) + veille = _veille(date_str) + cands = [] + + def _fin_activite(date_op: str) -> int | None: + row = p_by_date.get(date_op) or {} + fin = row.get("astreinte_fin") + return hhmm_to_minutes(fin) if fin else None + + # Fin saisie sur la veille : fin de soirée → repos terminé aujourd'hui ; fin + # matinale sur un jour d'astreinte → la nuit a dépassé minuit, le repos se + # termine aussi aujourd'hui. + if jour_astreinte_qualifie(veille, astreintes): + fin = _fin_activite(veille) + if fin is not None: + if fin + REPOS_QUOTIDIEN_MIN >= 24 * 60: + cands.append(fin + REPOS_QUOTIDIEN_MIN - 24 * 60) + elif veille in astreintes and fin < matin_debut: + cands.append(fin + REPOS_QUOTIDIEN_MIN) + # Fin matinale saisie sur le jour même (lendemain d'astreinte) : la nuit de + # la veille s'est terminée après minuit. + if veille in astreintes: + fin = _fin_activite(date_str) + if fin is not None and fin < matin_debut: + cands.append(fin + REPOS_QUOTIDIEN_MIN) + if not cands: + return None + best = max(cands) + # Un repos qui se termine avant l'ouverture ne contraint rien + return best if best > matin_debut else None + + +def heures_travaillees(p: dict, retour_min: int | None = None) -> int: """Return worked minutes for a pointage row. If a half-day has started but has no exit time yet, count elapsed time up to now - (today only — past dates with missing exits are treated as 0). + (today only — past dates with missing exits are treated as 0). Quand une heure + de retour est posée (repos de 11h après astreinte), la présence avant cette + heure ne compte pas dans les heures de la semaine : les entrées sont décalées + au moins à l'heure de retour. """ today = datetime.today().strftime("%Y-%m-%d") now_min = datetime.today().hour * 60 + datetime.today().minute + def entree_min(v): + m = hhmm_to_minutes(v) + return max(m, retour_min) if retour_min is not None else m + matin = 0 if p["matin_entree"] and p["matin_sortie"]: - matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - hhmm_to_minutes(p["matin_entree"])) + matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - entree_min(p["matin_entree"])) elif p["matin_entree"] and p.get("date") == today: - matin = max(0, now_min - hhmm_to_minutes(p["matin_entree"])) + matin = max(0, now_min - entree_min(p["matin_entree"])) aprem = 0 if p["aprem_entree"] and p["aprem_sortie"]: - aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - hhmm_to_minutes(p["aprem_entree"])) + aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - entree_min(p["aprem_entree"])) elif p["aprem_entree"] and p.get("date") == today: - aprem = max(0, now_min - hhmm_to_minutes(p["aprem_entree"])) + aprem = max(0, now_min - entree_min(p["aprem_entree"])) 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.""" +def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int, + plages: dict | None = None, retour_min: int | None = None) -> int: + """Return expected minutes for a date, accounting for vacations. Si une heure de + retour est définie (repos de 11h après astreinte), la présence obligatoire avant + cette heure est retirée du décompte de la semaine.""" d = datetime.strptime(date_str, "%Y-%m-%d").date() # Weekend = no hours due if d.weekday() >= 5: @@ -65,14 +167,21 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int) -> int: half = heures_jour_min // 2 if "matin" in conges_date or "aprem" in conges_date: - return half + du = half + else: + du = heures_jour_min - return heures_jour_min + if retour_min is not None and plages: + matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages) + du -= matin_ex + aprem_ex + + return max(0, du) -def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None) -> dict: +def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None) -> dict: """Compute totals for a list of pointages (one week).""" plages = {**DEFAULT_PLAGES, **(plages or {})} + astreintes = set(astreintes or []) matin_debut_min = hhmm_to_minutes(plages["matin_debut"]) matin_fin_min = hhmm_to_minutes(plages["matin_fin"]) aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"]) @@ -83,10 +192,18 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int total_travaille = 0 total_du = 0 jours = [] + p_by_date = {p["date"]: p for p in pointages} for p in pointages: - travaille = heures_travaillees(p) - du = heures_dues(p["date"], conges, heures_jour_min) + conges_date = {c["type"] for c in conges if c["date"] == p["date"]} + # Retour au bureau après 11h de repos, calculé depuis l'heure de fin de la + # dernière activité d'astreinte (saisie sur le jour ou la veille qualifiés) + retour_min = retour_apres_repos(p["date"], p_by_date, plages, astreintes) + matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages) + travaille = heures_travaillees(p, retour_min) + du = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min) + du_full = du + matin_ex + aprem_ex + matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex) delta = travaille - du total_travaille += travaille total_du += du @@ -98,14 +215,25 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "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"]}, + "conge": conges_date, + "du_full_min": du_full, + "matin_excl_min": matin_ex, + "aprem_excl_min": aprem_ex, + "matin_nominal_min": matin_nom, + "aprem_nominal_min": aprem_nom, + "astreinte": p["date"] in astreintes, + "astreinte_qualifiee": jour_astreinte_qualifie(p["date"], astreintes), + "retour_min": retour_min, + "retour": minutes_to_time(retour_min) if retour_min is not None else None, }) - # Mark each day as complete (all non-congé slots filled, or day is a congé/weekend) + # Mark each day as complete (all non-congé slots filled, or day is a congé/weekend). + # Une demi-journée entièrement couverte par le repos de 11h (retour après sa + # plage) n'a plus rien à remplir : elle compte comme faite. 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 + matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg or jour["matin_nominal_min"] == 0 + aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg or jour["aprem_nominal_min"] == 0 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 @@ -146,10 +274,11 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int for jour in jours: if jour["date"] != today: continue + retour = jour.get("retour_min") or 0 if jour.get("matin_entree") and not jour.get("matin_sortie"): - restant_planif += max(0, now_min - hhmm_to_minutes(jour["matin_entree"])) + restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["matin_entree"]), retour)) if jour.get("aprem_entree") and not jour.get("aprem_sortie"): - restant_planif += max(0, now_min - hhmm_to_minutes(jour["aprem_entree"])) + restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["aprem_entree"]), retour)) slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage for jour in jours: @@ -160,12 +289,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int du = jour["du_min"] if du == 0: continue - matin_nominal = 0 if "matin" in cg else (du if "aprem" in cg else du // 2) - aprem_nominal = 0 if "aprem" in cg else (du if "matin" in cg else du - du // 2) + matin_nominal = jour["matin_nominal_min"] + aprem_nominal = jour["aprem_nominal_min"] 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 if not matin_done: - debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else arrivee_visee_min + # L'arrivée visée ne peut pas précéder l'heure de retour (repos de 11h) + debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else max(arrivee_visee_min, jour["retour_min"] or 0) reprise = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min cap = max(0, min(matin_fin_min, reprise) - debut) # Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi @@ -175,9 +305,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int aprem_nominal += surplus # Le matin n'est jamais ajusté par le rattrapage hebdomadaire : seule sa # capacité avant la pause de midi compte, pour le calcul du surplus ci-dessus. - slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut}) + if cap > 0 and matin_nominal > 0: + slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut}) if not aprem_done: - debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min + debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else max(pause_dejeuner_fin_min, jour["retour_min"] or 0) cap = max(0, 23 * 60 + 59 - debut) cap_pref = min(cap, max(0, depart_vise_min - debut)) if depart_vise_min else cap # floor_room = marge disponible pour avancer le départ en cas d'avance, diff --git a/app/export.py b/app/export.py index 9c8a311..b812888 100644 --- a/app/export.py +++ b/app/export.py @@ -8,12 +8,15 @@ from odf.table import Table, TableColumn, TableRow, TableCell from odf.text import P from calcul import compute_week, minutes_to_hhmm -from models import load_week_pointages, load_conges, list_all_weeks, load_plages, heures_jour_min +from models import ( + load_week_pointages, load_conges, load_astreintes, list_all_weeks, + load_plages, heures_jour_min, +) JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"] HEADERS = [ "Date", "Jour", "Matin entrée", "Matin sortie", "Aprem entrée", "Aprem sortie", - "Travaillé", "Dû", "Δ jour", "Congé", + "Travaillé", "Dû", "Δ jour", "Congé", "Astreinte", "Retour", ] @@ -57,6 +60,12 @@ def _congé_label(cg: set) -> str: return " + ".join(parts) +def _astreinte_label(jour: dict) -> str: + if jour.get("astreinte_fin"): + return f"fin {jour['astreinte_fin']}" + return "Astreinte" if jour.get("astreinte") else "" + + def _jour_row(jour: dict, nom: str) -> TableRow: row = TableRow() for v in ( @@ -66,6 +75,8 @@ def _jour_row(jour: dict, nom: str) -> TableRow: jour["travaille"], jour["du"], ("+" if jour["delta_min"] > 0 else "") + jour["delta"], _congé_label(jour["conge"]), + _astreinte_label(jour), + jour.get("retour"), ): row.addElement(_cell(v)) return row @@ -76,7 +87,7 @@ def _totals_row(bold: Style, travaille_min: int, du_min: int) -> TableRow: for v in ("Total", "", "", "", "", "", minutes_to_hhmm(travaille_min), minutes_to_hhmm(du_min), ("+" if travaille_min - du_min > 0 else "") + minutes_to_hhmm(travaille_min - du_min), - ""): + "", "", ""): row.addElement(_cell(v, bold)) return row @@ -92,6 +103,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes: h_jour = heures_jour_min() plages = load_plages(user_id) conges = load_conges(user_id) + astreintes = load_astreintes(user_id) dates = _week_dates(year, week) raw = load_week_pointages(year, week, user_id) @@ -101,7 +113,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes: "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - result = compute_week(pointages, conges, h_jour, plages) + result = compute_week(pointages, conges, h_jour, plages, astreintes) table = Table(name=f"Semaine {week:02d}-{year}") _size_columns(table, wide_col) @@ -123,6 +135,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes: h_jour = heures_jour_min() plages = load_plages(user_id) conges = load_conges(user_id) + astreintes = load_astreintes(user_id) weeks = sorted(wk for yr, wk in list_all_weeks(user_id) if yr == year) @@ -136,7 +149,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes: "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - result = compute_week(pointages, conges, h_jour, plages) + result = compute_week(pointages, conges, h_jour, plages, astreintes) travaille_min = sum(j["travaille_min"] for j in result["jours"]) du_min = sum(j["du_min"] for j in result["jours"]) par_semaine.append((week, result, travaille_min, du_min)) diff --git a/app/main.py b/app/main.py index d590b5d..cf83401 100644 --- a/app/main.py +++ b/app/main.py @@ -21,14 +21,18 @@ from fastapi.staticfiles import StaticFiles from mail import send_mail from models import ( load_config, load_week_pointages, save_week_pointages, load_conges, - toggle_conge, list_all_weeks, + toggle_conge, list_all_weeks, load_astreintes, toggle_astreinte, load_notif_config, save_notif_config, find_user_by_token, save_presence, regenerate_presence_token, load_auth, save_auth, find_user_by_reset_token, load_plages, save_plages, heures_jour_min, append_log, load_logs, load_or_create_secret, ) -from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues +from calcul import ( + compute_week, minutes_to_hhmm, minutes_to_time, hhmm_to_minutes, heures_dues, + heures_travaillees, jour_astreinte_qualifie, exclusion_retour, + nominaux_du_jour, retour_apres_repos, +) from stats import compute_all_stats from export import export_semaine_ods, export_annee_ods from notifications import reminder_loop, send_ntfy, is_safe_ntfy_server @@ -303,21 +307,29 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id): "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - result = compute_week(pointages, conges, h_jour, load_plages(user_id)) + result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id)) result["heures_jour"] = minutes_to_hhmm(h_jour) return {"result": result} def _build_jour_ctx(date, conges, h_jour, user_id): - from calcul import heures_travaillees d = datetime.strptime(date, "%Y-%m-%d").date() iso = d.isocalendar() raw = load_week_pointages(iso.year, iso.week, user_id) + plages = load_plages(user_id) + astreintes = set(load_astreintes(user_id)) 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) + conges_date = {c["type"] for c in conges if c["date"] == date} + # Retour au bureau après 11h de repos, calculé depuis la fin de la dernière + # activité d'astreinte (saisie sur le jour ou la veille qualifiés) + retour_min = retour_apres_repos(date, p_by_date, plages, astreintes) + matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages) + trav = heures_travaillees(p, retour_min) + du = heures_dues(date, conges, h_jour, plages, retour_min) + du_full = du + matin_ex + aprem_ex + matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex) delta = trav - du return { **p, @@ -325,7 +337,14 @@ def _build_jour_ctx(date, conges, h_jour, user_id): "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}, + "conge": conges_date, + "du_full_min": du_full, + "matin_excl_min": matin_ex, "aprem_excl_min": aprem_ex, + "matin_nominal_min": matin_nom, "aprem_nominal_min": aprem_nom, + "astreinte": date in astreintes, + "astreinte_qualifiee": jour_astreinte_qualifie(date, astreintes), + "retour_min": retour_min, + "retour": minutes_to_time(retour_min) if retour_min is not None else None, } @@ -372,13 +391,18 @@ def _oob_time_cells(date, jour, plages): cg = jour["conge"] matin_cg = "matin" in cg or "jour" in cg aprem_cg = "aprem" in cg or "jour" in cg - matin_warn = not matin_cg and jour["du_min"] > 0 and ( - (jour.get("matin_entree") and jour["matin_entree"] > plages["matin_debut"]) or - (jour.get("matin_sortie") and jour["matin_sortie"] < plages["matin_fin"]) + retour_min = jour.get("retour_min") or 0 + # Une demi-journée entièrement couverte par le repos de 11h n'est plus exigée + matin_requis = not matin_cg and jour["du_min"] > 0 and jour.get("matin_nominal_min", 0) > 0 + aprem_requis = not aprem_cg and jour["du_min"] > 0 and jour.get("aprem_nominal_min", 0) > 0 + # L'heure de retour décale d'autant la limite d'arrivée admise sans avertissement + matin_warn = matin_requis and ( + (jour.get("matin_entree") and hhmm_to_minutes(jour["matin_entree"]) > max(hhmm_to_minutes(plages["matin_debut"]), retour_min)) or + (jour.get("matin_sortie") and hhmm_to_minutes(jour["matin_sortie"]) < hhmm_to_minutes(plages["matin_fin"])) ) - aprem_warn = not aprem_cg and jour["du_min"] > 0 and ( - (jour.get("aprem_entree") and jour["aprem_entree"] > plages["aprem_debut"]) or - (jour.get("aprem_sortie") and jour["aprem_sortie"] < plages["aprem_fin"]) + aprem_warn = aprem_requis and ( + (jour.get("aprem_entree") and hhmm_to_minutes(jour["aprem_entree"]) > max(hhmm_to_minutes(plages["aprem_debut"]), retour_min)) or + (jour.get("aprem_sortie") and hhmm_to_minutes(jour["aprem_sortie"]) < hhmm_to_minutes(plages["aprem_fin"])) ) 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") @@ -401,22 +425,49 @@ def _oob_time_cells(date, jour, plages): return matin_html + aprem_html -def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id): - iso = d.isocalendar() - plages = load_plages(user_id) - jour = _build_jour_ctx(date, conges, h_jour, user_id) +def _btns_html(date, jour): + """Boutons congé + astreinte d'une ligne (cible du swap HTMX).""" 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 = ( + btn_j_on = " jour-on" if "jour" in cg else "" + btn_as_on = " as-on" if jour.get("astreinte") else "" + return ( f'