1806 lines
77 KiB
Python
1806 lines
77 KiB
Python
import base64
|
|
import calendar
|
|
import io
|
|
import json
|
|
import os
|
|
import reflex as rx
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
from sqlalchemy import select
|
|
|
|
DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
|
|
_SETTINGS_FILE = DATA_DIR / "settings.json"
|
|
|
|
from ..state import AuthState
|
|
from ..sidebar import layout
|
|
from src.db import (
|
|
get_session, Apprenti, Absence, ApprentiFiche,
|
|
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
|
|
upsert_escada_pending,
|
|
)
|
|
from src.stats import nb_blocs_absences
|
|
from src.parser_bn import sem_short_label, sem_year_only
|
|
from src.email_sender import build_template_vars, render_template
|
|
from src.logger import app_log
|
|
from src.user_access import get_allowed_classes, is_class_allowed
|
|
from ..components import empty_state
|
|
|
|
MOIS_FR = [
|
|
"janvier", "fevrier", "mars", "avril", "mai", "juin",
|
|
"juillet", "aout", "septembre", "octobre", "novembre", "decembre",
|
|
]
|
|
QUOTA = 5
|
|
|
|
_GROUP_LABELS = {
|
|
"CG": "Culture Gen.",
|
|
"BP": "Branches Prof.",
|
|
"TP": "Trav. Pratiques",
|
|
}
|
|
_GROUP_ORDER = {"DUAL": ["CG", "BP"], "EM": ["BP", "TP"]}
|
|
|
|
|
|
def _read_settings() -> dict:
|
|
if _SETTINGS_FILE.exists():
|
|
try:
|
|
return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
return {}
|
|
|
|
|
|
# ── HTML generators ───────────────────────────────────────────────────────────
|
|
|
|
def _bn_fmt(v) -> str:
|
|
if v is None:
|
|
return ""
|
|
try:
|
|
return f"{float(v):.1f}".replace(".", ",")
|
|
except (TypeError, ValueError):
|
|
return ""
|
|
|
|
|
|
def _bn_cell_style(v) -> str:
|
|
base = "border:1px solid #dee2e6;padding:5px 10px;text-align:center"
|
|
if v is None:
|
|
return f"{base};color:#bbb"
|
|
try:
|
|
if float(v) < 4.0:
|
|
return f"{base};background:#ffcccc;color:#B71C1C;font-weight:bold"
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return base
|
|
|
|
|
|
def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
|
|
N = 8
|
|
TD = "border:1px solid #dee2e6;padding:5px 10px"
|
|
TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa"
|
|
SEP = ";border-top:3px solid #9e9e9e"
|
|
|
|
header = f'<th style="{TH};text-align:left;min-width:230px"></th>'
|
|
for i in range(N):
|
|
raw = sem_labels[i] if i < len(sem_labels) else None
|
|
short = sem_short_label(raw, i)
|
|
year = sem_year_only(raw)
|
|
year_html = (
|
|
f'<div style="font-size:0.78em;color:#666;font-weight:normal;margin-top:1px">{year}</div>'
|
|
if year else ""
|
|
)
|
|
header += f'<th style="{TH}"><div style="font-weight:700">{short}</div>{year_html}</th>'
|
|
|
|
def _moy_sem_row(label, gd, label_style, sep=False):
|
|
s = SEP if sep else ""
|
|
cells = f'<td style="{label_style}{s}">{label}</td>'
|
|
for i in range(N):
|
|
v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
|
|
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
|
|
return f"<tr>{cells}</tr>"
|
|
|
|
def _moy_ann_row(label, gd, label_style, sep=False):
|
|
s = SEP if sep else ""
|
|
cells = f'<td style="{label_style}{s}">{label}</td>'
|
|
for year_start in range(0, N, 2):
|
|
v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None
|
|
cells += f'<td colspan="2" style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
|
|
return f"<tr>{cells}</tr>"
|
|
|
|
body = ""
|
|
for grp in groups_order:
|
|
gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N})
|
|
lbl = _GROUP_LABELS.get(grp, grp)
|
|
body += _moy_sem_row(lbl, gd, f"{TD};font-weight:bold")
|
|
body += _moy_ann_row("Moyenne annuelle du groupe", gd, f"{TD};font-style:italic;color:#555")
|
|
|
|
body += _moy_sem_row("Moyenne semestrielle globale", d["globale"], f"{TD};font-style:italic", sep=True)
|
|
body += _moy_ann_row("Moyenne annuelle globale", d["globale"], f"{TD};font-weight:bold")
|
|
|
|
return (
|
|
f'<div style="overflow-x:auto;max-width:100%;margin-bottom:16px">'
|
|
f'<table style="min-width:560px;width:100%;border-collapse:collapse;font-size:0.875em">'
|
|
f"<thead><tr>{header}</tr></thead>"
|
|
f"<tbody>{body}</tbody>"
|
|
f"</table></div>"
|
|
)
|
|
|
|
|
|
def _matu_html_table(nm) -> str:
|
|
TD = "border:1px solid #dee2e6;padding:5px 10px"
|
|
TDc = f"{TD};text-align:center"
|
|
|
|
def _cell_moy(v):
|
|
if v is None:
|
|
return f'<td style="{TDc};color:#bbb">—</td>'
|
|
style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if v < 4.0 else TDc
|
|
return f'<td style="{style}">{_bn_fmt(v)}</td>'
|
|
|
|
def _cell_prom(p, info):
|
|
if not p:
|
|
return f'<td style="{TDc};color:#bbb">—</td><td style="{TD}"></td>'
|
|
red = p == "NB"
|
|
style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if red else TDc
|
|
return (
|
|
f'<td style="{style}">{p}</td>'
|
|
f'<td style="{TD};color:#555;font-style:italic">{info or ""}</td>'
|
|
)
|
|
|
|
rows = (
|
|
f'<tr><td style="{TD}">Moyenne du semestre</td>{_cell_moy(nm.moy)}<td style="{TD}"></td></tr>'
|
|
f'<tr><td style="{TD}">Promotion</td>{_cell_prom(nm.promotion, nm.prom_info)}</tr>'
|
|
)
|
|
header_div = (
|
|
f'<div style="font-size:0.95em;font-weight:bold;padding:6px 12px;'
|
|
f'background:#f9fbe7;border-radius:4px 4px 0 0;border:1px solid #dee2e6;'
|
|
f'border-bottom:none">Matu — {nm.classe_mp} — {nm.sem_label}</div>'
|
|
)
|
|
return (
|
|
f'<div style="overflow-x:auto;margin-bottom:16px">{header_div}'
|
|
f'<table style="border-collapse:collapse;font-size:0.875em;border-top:none">'
|
|
f"<tbody>{rows}</tbody></table></div>"
|
|
)
|
|
|
|
|
|
def _render_notes_html(notes_data: list) -> str:
|
|
html = (
|
|
"<style>"
|
|
".nt table{border-collapse:collapse;width:100%;font-size:0.82rem;margin-bottom:2px}"
|
|
".nt th{background:#f0f2f6;padding:5px 10px;text-align:left;font-size:0.78rem;"
|
|
"color:#555;font-weight:600;letter-spacing:.02em}"
|
|
".nt td{padding:5px 10px;border-bottom:1px solid #f0f0f0;vertical-align:middle}"
|
|
".nt tr:last-child td{border-bottom:none}"
|
|
".nt .bh{display:flex;justify-content:space-between;align-items:center;"
|
|
"margin:18px 0 0;padding:7px 12px;border-radius:6px 6px 0 0;"
|
|
"background:#e8eaf6;border-left:4px solid #3949ab}"
|
|
".nt .bh.insuf{background:#ffebee;border-left:4px solid #c62828}"
|
|
".nt .bn{font-weight:700;font-size:0.9rem;color:#1a237e;letter-spacing:.01em}"
|
|
".nt .bh.insuf .bn{color:#b71c1c}"
|
|
".nt .moy{font-size:0.85rem;color:#555}"
|
|
"</style><div class='nt'>"
|
|
)
|
|
for _br in notes_data:
|
|
_moy = _br.get("moy_arr")
|
|
_moy_prov = _br.get("moy_prov")
|
|
_insuf = _moy is not None and float(_moy) < 4.0
|
|
_mc = "#c62828" if _insuf else ("#e65100" if _moy and float(_moy) < 5.0 else "#2e7d32")
|
|
_br_name = ("⚠ " if _insuf else "") + _br["branche"]
|
|
_moy_html = (
|
|
f'<span style="font-weight:700;color:{_mc}">{_moy}</span>'
|
|
+ (f' <span style="font-size:0.8em;color:#888">({_moy_prov})</span>'
|
|
if _moy_prov is not None else "")
|
|
) if _moy is not None else "—"
|
|
_insuf_cls = " insuf" if _insuf else ""
|
|
html += (
|
|
f'<div class="bh{_insuf_cls}">'
|
|
f'<span class="bn">{_br_name}</span>'
|
|
f'<span class="moy">Moyenne : {_moy_html}</span></div>'
|
|
'<div style="overflow-x:auto;max-width:100%">'
|
|
'<table style="table-layout:fixed;min-width:520px;width:100%">'
|
|
'<colgroup>'
|
|
'<col style="width:90px">'
|
|
'<col>'
|
|
'<col style="width:90px">'
|
|
'<col style="width:55px">'
|
|
'<col style="width:110px">'
|
|
'<col style="width:65px">'
|
|
'</colgroup>'
|
|
"<tr><th>Date</th><th>Examen</th><th>Enseignant</th>"
|
|
"<th>Coeff</th><th>Type</th><th>Note</th></tr>"
|
|
)
|
|
for _ex in _br.get("examens", []):
|
|
_n = _ex["note"]
|
|
if _n is None:
|
|
_note_html = '<span style="color:#bbb">—</span>'
|
|
elif _n == "disp.":
|
|
_note_html = '<span style="color:#888;font-style:italic">disp.</span>'
|
|
else:
|
|
_nc = "#c62828" if float(_n) < 4.0 else ("#e65100" if float(_n) < 5.0 else "#2e7d32")
|
|
_disp_tag = (
|
|
' <span style="font-size:0.75em;color:#888">[disp.]</span>'
|
|
if _ex.get("dispensed") else ""
|
|
)
|
|
_note_html = f'<span style="font-weight:700;color:{_nc}">{_n}</span>{_disp_tag}'
|
|
html += (
|
|
f'<tr><td>{_ex["date"]}</td>'
|
|
f'<td>{_ex["description"]}</td>'
|
|
f'<td style="color:#555">{_ex["enseignant"]}</td>'
|
|
f'<td style="text-align:center">{_ex["coefficient"] or ""}</td>'
|
|
f'<td style="color:#777">{_ex["type"]}</td>'
|
|
f'<td style="text-align:center">{_note_html}</td></tr>'
|
|
)
|
|
html += "</table></div>"
|
|
html += "</div>"
|
|
return html
|
|
|
|
|
|
def _absence_pdf_apprenti(sess, apprenti) -> bytes:
|
|
from reportlab.lib import colors as _rl_colors
|
|
from reportlab.lib.pagesizes import A4, landscape
|
|
from reportlab.lib.styles import getSampleStyleSheet
|
|
from reportlab.lib.units import cm
|
|
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
|
|
|
absences = sess.execute(
|
|
select(Absence)
|
|
.where(Absence.apprenti_id == apprenti.id)
|
|
.order_by(Absence.date, Absence.periode)
|
|
).scalars().all()
|
|
|
|
by_date: dict = {}
|
|
for ab in absences:
|
|
by_date.setdefault(ab.date, {})[ab.periode] = "E" if ab.statut == "excusee" else "N"
|
|
|
|
sorted_dates = sorted(by_date)
|
|
blocs: list = []
|
|
if sorted_dates:
|
|
dates_set = set(sorted_dates)
|
|
cur = [sorted_dates[0]]
|
|
for d in sorted_dates[1:]:
|
|
check = cur[-1] + timedelta(days=1)
|
|
gap_ok = True
|
|
while check < d:
|
|
if check.weekday() < 5 and check not in dates_set:
|
|
gap_ok = False
|
|
break
|
|
check += timedelta(days=1)
|
|
if gap_ok:
|
|
cur.append(d)
|
|
else:
|
|
blocs.append(cur)
|
|
cur = [d]
|
|
blocs.append(cur)
|
|
|
|
DARK = _rl_colors.HexColor("#37474F")
|
|
BLUE_BG = _rl_colors.HexColor("#E3F2FD")
|
|
BLUE_FG = _rl_colors.HexColor("#0D47A1")
|
|
RED_BG = _rl_colors.HexColor("#FFEBEE")
|
|
RED_FG = _rl_colors.HexColor("#B71C1C")
|
|
GREY_BG = _rl_colors.HexColor("#F5F5F5")
|
|
FOOT_BG = _rl_colors.HexColor("#ECEFF1")
|
|
|
|
data = [["Abs.", "Date"] + [f"P{i}" for i in range(1, 11)]]
|
|
styles_tbl = [
|
|
("BACKGROUND", (0, 0), (-1, 0), DARK),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), _rl_colors.white),
|
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
|
("FONTSIZE", (0, 0), (-1, -1), 9),
|
|
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("GRID", (0, 0), (-1, -1), 0.5, _rl_colors.HexColor("#CCCCCC")),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [_rl_colors.white, GREY_BG]),
|
|
]
|
|
|
|
total_e = total_n = 0
|
|
row_idx = 1
|
|
|
|
for bloc_num, bloc_dates in enumerate(blocs, start=1):
|
|
first_row = row_idx
|
|
for i, d in enumerate(bloc_dates):
|
|
periods = by_date[d]
|
|
row = [str(bloc_num) if i == 0 else "", d.strftime("%d.%m.%Y")]
|
|
for p in range(1, 11):
|
|
val = periods.get(p, "")
|
|
row.append(val)
|
|
if val == "E":
|
|
total_e += 1
|
|
styles_tbl += [
|
|
("BACKGROUND", (p + 1, row_idx), (p + 1, row_idx), BLUE_BG),
|
|
("TEXTCOLOR", (p + 1, row_idx), (p + 1, row_idx), BLUE_FG),
|
|
("FONTNAME", (p + 1, row_idx), (p + 1, row_idx), "Helvetica-Bold"),
|
|
]
|
|
elif val == "N":
|
|
total_n += 1
|
|
styles_tbl += [
|
|
("BACKGROUND", (p + 1, row_idx), (p + 1, row_idx), RED_BG),
|
|
("TEXTCOLOR", (p + 1, row_idx), (p + 1, row_idx), RED_FG),
|
|
("FONTNAME", (p + 1, row_idx), (p + 1, row_idx), "Helvetica-Bold"),
|
|
]
|
|
data.append(row)
|
|
row_idx += 1
|
|
if row_idx - first_row > 1:
|
|
styles_tbl.append(("SPAN", (0, first_row), (0, row_idx - 1)))
|
|
|
|
total_row = row_idx
|
|
total_periodes = total_e + total_n
|
|
footer_label = (
|
|
f"{len(blocs)} absence(s) | "
|
|
f"{total_periodes} période(s) | "
|
|
f"{total_e} excusee(s) | "
|
|
f"{total_n} non excusee(s)"
|
|
)
|
|
data.append([footer_label] + [""] * 11)
|
|
styles_tbl += [
|
|
("SPAN", (0, total_row), (-1, total_row)),
|
|
("BACKGROUND", (0, total_row), (-1, total_row), FOOT_BG),
|
|
("FONTNAME", (0, total_row), (-1, total_row), "Helvetica-Bold"),
|
|
("ALIGN", (0, total_row), (-1, total_row), "LEFT"),
|
|
("LEFTPADDING", (0, total_row), (-1, total_row), 8),
|
|
]
|
|
|
|
col_w = [1.5 * cm, 2.8 * cm] + [2.24 * cm] * 10
|
|
t = Table(data if len(data) > 1 else [data[0]], colWidths=col_w, repeatRows=1)
|
|
t.setStyle(TableStyle(styles_tbl))
|
|
|
|
buf = io.BytesIO()
|
|
doc = SimpleDocTemplate(
|
|
buf, pagesize=landscape(A4),
|
|
leftMargin=1.5 * cm, rightMargin=1.5 * cm,
|
|
topMargin=1.5 * cm, bottomMargin=1.5 * cm,
|
|
)
|
|
styles = getSampleStyleSheet()
|
|
title = Paragraph(
|
|
f"<b>Absences - {apprenti.nom} {apprenti.prenom}</b>"
|
|
f" Classe : {apprenti.classe}",
|
|
styles["Normal"],
|
|
)
|
|
doc.build([title, Spacer(1, 0.5 * cm), t])
|
|
return buf.getvalue()
|
|
|
|
|
|
def _extract_bn_pages(pdf_path, nom: str, prenom: str) -> bytes | None:
|
|
try:
|
|
import pdfplumber
|
|
from pypdf import PdfWriter, PdfReader
|
|
except ImportError:
|
|
return None
|
|
try:
|
|
pages_to_extract = []
|
|
with pdfplumber.open(str(pdf_path)) as pdf:
|
|
for i, page in enumerate(pdf.pages):
|
|
text = page.extract_text() or ""
|
|
if nom.upper() in text.upper() and prenom.upper() in text.upper():
|
|
pages_to_extract.append(i)
|
|
if not pages_to_extract:
|
|
return None
|
|
reader = PdfReader(str(pdf_path))
|
|
writer = PdfWriter()
|
|
for i in pages_to_extract:
|
|
writer.add_page(reader.pages[i])
|
|
buf = io.BytesIO()
|
|
writer.write(buf)
|
|
return buf.getvalue()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ── State ─────────────────────────────────────────────────────────────────────
|
|
|
|
class FicheState(AuthState):
|
|
# ── Apprenti selector ────────────────────────────────────────────────────
|
|
apprenti_labels: list[str] = []
|
|
apprenti_ids: list[int] = []
|
|
selected_label: str = ""
|
|
selected_id: int = 0
|
|
has_apprentis: bool = False
|
|
apprenti_search: str = ""
|
|
apprenti_select_open: bool = False
|
|
|
|
@rx.var
|
|
def filtered_apprenti_labels(self) -> list[str]:
|
|
q = self.apprenti_search.lower().strip()
|
|
if not q:
|
|
return self.apprenti_labels
|
|
return [l for l in self.apprenti_labels if q in l.lower()]
|
|
|
|
# ── KPIs ─────────────────────────────────────────────────────────────────
|
|
kpi_total: int = 0
|
|
kpi_excusees: int = 0
|
|
kpi_non_excusees: int = 0
|
|
kpi_blocs: int = 0
|
|
quota_atteint: bool = False
|
|
|
|
# ── Calendar ─────────────────────────────────────────────────────────────
|
|
cal_year: int = 0
|
|
cal_month: int = 0
|
|
cal_month_name: str = ""
|
|
cal_prev_name: str = ""
|
|
cal_next_name: str = ""
|
|
cal_days: list[dict] = []
|
|
|
|
# ── Pending dates (quick excuse) ─────────────────────────────────────────
|
|
pending_dates: list[dict] = []
|
|
|
|
# ── Calendar day edit ─────────────────────────────────────────────────────
|
|
edit_date: str = ""
|
|
edit_date_label: str = ""
|
|
edit_p1: str = "present"
|
|
edit_p2: str = "present"
|
|
edit_p3: str = "present"
|
|
edit_p4: str = "present"
|
|
edit_p5: str = "present"
|
|
edit_p6: str = "present"
|
|
edit_p7: str = "present"
|
|
edit_p8: str = "present"
|
|
edit_p9: str = "present"
|
|
edit_p10: str = "present"
|
|
|
|
# ── Escada fiche ─────────────────────────────────────────────────────────
|
|
fiche_available: bool = False
|
|
fiche_adresse: str = ""
|
|
fiche_cp_localite: str = ""
|
|
fiche_telephone: str = ""
|
|
fiche_email_val: str = ""
|
|
fiche_date_naissance: str = ""
|
|
fiche_majeur: str = ""
|
|
fiche_entreprise_nom: str = ""
|
|
fiche_entreprise_adresse: str = ""
|
|
fiche_entreprise_cp_localite: str = ""
|
|
fiche_entreprise_telephone: str = ""
|
|
fiche_entreprise_email: str = ""
|
|
fiche_formateur_nom: str = ""
|
|
fiche_formateur_email: str = ""
|
|
fiche_updated_at: str = ""
|
|
|
|
# ── Bulletin de notes ─────────────────────────────────────────────────────
|
|
has_bn: bool = False
|
|
bn_html: str = ""
|
|
bn_caption: str = ""
|
|
has_notes: bool = False
|
|
notes_html: str = ""
|
|
bn_pdf_fichier: str = ""
|
|
has_pdf_bn: bool = False
|
|
has_pdf_notes: bool = False
|
|
|
|
# ── Email ─────────────────────────────────────────────────────────────────
|
|
smtp_ok: bool = False
|
|
email_dest: str = "apprenti"
|
|
email_custom: str = ""
|
|
email_subject: str = ""
|
|
email_body: str = ""
|
|
email_attach_abs: bool = True
|
|
email_attach_bn: bool = False
|
|
email_attach_notes: bool = False
|
|
email_sending: bool = False
|
|
email_sent: bool = False
|
|
email_error: str = ""
|
|
|
|
# ── Setters (edit periods) ────────────────────────────────────────────────
|
|
# Note: rx.segmented_control passe str | list[str] — on coerce.
|
|
@staticmethod
|
|
def _coerce_period(v) -> str:
|
|
if isinstance(v, list):
|
|
return v[0] if v else "present"
|
|
return v or "present"
|
|
|
|
def set_edit_p1(self, v): self.edit_p1 = self._coerce_period(v)
|
|
def set_edit_p2(self, v): self.edit_p2 = self._coerce_period(v)
|
|
def set_edit_p3(self, v): self.edit_p3 = self._coerce_period(v)
|
|
def set_edit_p4(self, v): self.edit_p4 = self._coerce_period(v)
|
|
def set_edit_p5(self, v): self.edit_p5 = self._coerce_period(v)
|
|
def set_edit_p6(self, v): self.edit_p6 = self._coerce_period(v)
|
|
def set_edit_p7(self, v): self.edit_p7 = self._coerce_period(v)
|
|
def set_edit_p8(self, v): self.edit_p8 = self._coerce_period(v)
|
|
def set_edit_p9(self, v): self.edit_p9 = self._coerce_period(v)
|
|
def set_edit_p10(self, v): self.edit_p10 = self._coerce_period(v)
|
|
|
|
# ── Setters (email) ───────────────────────────────────────────────────────
|
|
def set_email_dest(self, v: str): self.email_dest = v
|
|
def set_email_custom(self, v: str): self.email_custom = v
|
|
def set_email_subject(self, v: str): self.email_subject = v
|
|
def set_email_body(self, v: str): self.email_body = v
|
|
def toggle_attach_abs(self, v: bool): self.email_attach_abs = v
|
|
def toggle_attach_bn(self, v: bool): self.email_attach_bn = v
|
|
def toggle_attach_notes(self, v: bool): self.email_attach_notes = v
|
|
|
|
# ── Page load ─────────────────────────────────────────────────────────────
|
|
def load_data(self):
|
|
if not self.authenticated:
|
|
return rx.redirect("/login")
|
|
sess = get_session()
|
|
allowed = get_allowed_classes(self.username)
|
|
q = select(Apprenti).order_by(Apprenti.nom, Apprenti.prenom)
|
|
if allowed is not None:
|
|
q = q.where(Apprenti.classe.in_(allowed))
|
|
apprentis = sess.execute(q).scalars().all()
|
|
if not apprentis:
|
|
self.has_apprentis = False
|
|
self.apprenti_labels = []
|
|
self.apprenti_ids = []
|
|
return
|
|
self.has_apprentis = True
|
|
self.apprenti_labels = [
|
|
f"{a.nom} {a.prenom} ({a.classe})" for a in apprentis
|
|
]
|
|
self.apprenti_ids = [a.id for a in apprentis]
|
|
if self.selected_id == 0 or self.selected_id not in self.apprenti_ids:
|
|
self.selected_id = self.apprenti_ids[0]
|
|
self.selected_label = self.apprenti_labels[0]
|
|
self._reload(reset_email=True)
|
|
|
|
def handle_select(self, label: str):
|
|
self.selected_label = label
|
|
try:
|
|
idx = self.apprenti_labels.index(label)
|
|
self.selected_id = self.apprenti_ids[idx]
|
|
except ValueError:
|
|
pass
|
|
self.edit_date = ""
|
|
self.apprenti_select_open = False
|
|
self.apprenti_search = ""
|
|
self._reload(reset_email=True)
|
|
|
|
def set_apprenti_search(self, v: str):
|
|
self.apprenti_search = v
|
|
|
|
def set_apprenti_select_open(self, v: bool):
|
|
self.apprenti_select_open = v
|
|
if not v:
|
|
self.apprenti_search = ""
|
|
|
|
def apprenti_search_keydown(self, key: str):
|
|
"""Enter → sélectionne le 1er résultat. Esc → ferme."""
|
|
if key == "Enter":
|
|
results = self.filtered_apprenti_labels
|
|
if results:
|
|
return FicheState.handle_select(results[0])
|
|
elif key == "Escape":
|
|
self.apprenti_select_open = False
|
|
self.apprenti_search = ""
|
|
|
|
def navigate_to(self, apprenti_id: int):
|
|
# Garde-fou : revérifie que l'apprenti est dans le scope autorisé
|
|
sess = get_session()
|
|
try:
|
|
ap = sess.get(Apprenti, apprenti_id)
|
|
if ap is None or not is_class_allowed(self.username, ap.classe):
|
|
return
|
|
finally:
|
|
sess.close()
|
|
# Si l'apprenti n'est pas dans la liste actuelle (ex: liste pas encore
|
|
# chargée), on la recharge — load_data filtre déjà selon les droits.
|
|
if apprenti_id not in self.apprenti_ids:
|
|
self.load_data()
|
|
if apprenti_id in self.apprenti_ids:
|
|
idx = self.apprenti_ids.index(apprenti_id)
|
|
self.selected_id = apprenti_id
|
|
self.selected_label = self.apprenti_labels[idx]
|
|
self.edit_date = ""
|
|
self._reload(reset_email=True)
|
|
|
|
# ── Calendar navigation ───────────────────────────────────────────────────
|
|
def prev_month(self):
|
|
if self.cal_month == 1:
|
|
self.cal_month = 12
|
|
self.cal_year -= 1
|
|
else:
|
|
self.cal_month -= 1
|
|
self._rebuild_calendar()
|
|
|
|
def next_month(self):
|
|
if self.cal_month == 12:
|
|
self.cal_month = 1
|
|
self.cal_year += 1
|
|
else:
|
|
self.cal_month += 1
|
|
self._rebuild_calendar()
|
|
|
|
# ── Calendar day edit ─────────────────────────────────────────────────────
|
|
def select_day(self, date_str: str):
|
|
if not date_str:
|
|
return
|
|
if self.edit_date == date_str:
|
|
self.edit_date = ""
|
|
return
|
|
sess = get_session()
|
|
d = date.fromisoformat(date_str)
|
|
absences = sess.execute(
|
|
select(Absence).where(
|
|
Absence.apprenti_id == self.selected_id,
|
|
Absence.date == d,
|
|
)
|
|
).scalars().all()
|
|
pm = {ab.periode: ab.statut for ab in absences}
|
|
|
|
def _choice(p: int) -> str:
|
|
s = pm.get(p)
|
|
if s == "excusee": return "excusee"
|
|
if s == "a_traiter": return "non_excusee"
|
|
return "present"
|
|
|
|
self.edit_p1 = _choice(1)
|
|
self.edit_p2 = _choice(2)
|
|
self.edit_p3 = _choice(3)
|
|
self.edit_p4 = _choice(4)
|
|
self.edit_p5 = _choice(5)
|
|
self.edit_p6 = _choice(6)
|
|
self.edit_p7 = _choice(7)
|
|
self.edit_p8 = _choice(8)
|
|
self.edit_p9 = _choice(9)
|
|
self.edit_p10 = _choice(10)
|
|
self.edit_date_label = d.strftime("%d.%m.%Y")
|
|
self.edit_date = date_str
|
|
|
|
def cancel_edit(self):
|
|
self.edit_date = ""
|
|
|
|
def save_day_edit(self):
|
|
if not self.edit_date:
|
|
return
|
|
sess = get_session()
|
|
d = date.fromisoformat(self.edit_date)
|
|
apprenti = sess.get(Apprenti, self.selected_id)
|
|
appr_label = (
|
|
f"{apprenti.nom} {apprenti.prenom} ({apprenti.classe})"
|
|
if apprenti else f"id={self.selected_id}"
|
|
)
|
|
user = self.username or "?"
|
|
existing = sess.execute(
|
|
select(Absence).where(
|
|
Absence.apprenti_id == self.selected_id,
|
|
Absence.date == d,
|
|
)
|
|
).scalars().all()
|
|
pm = {ab.periode: ab for ab in existing}
|
|
choices = {
|
|
1: self.edit_p1, 2: self.edit_p2, 3: self.edit_p3,
|
|
4: self.edit_p4, 5: self.edit_p5, 6: self.edit_p6,
|
|
7: self.edit_p7, 8: self.edit_p8, 9: self.edit_p9,
|
|
10: self.edit_p10,
|
|
}
|
|
d_str = d.strftime("%d.%m.%Y")
|
|
nb_changes = 0
|
|
for p, choice in choices.items():
|
|
ab = pm.get(p)
|
|
if choice == "present":
|
|
if ab:
|
|
upsert_escada_pending(sess, self.selected_id, d, p, "clear")
|
|
sess.delete(ab)
|
|
nb_changes += 1
|
|
app_log(
|
|
f"[abs] {user} : {appr_label} — {d_str} P{p} : "
|
|
f"{ab.type_origine} → présent (suppression)"
|
|
)
|
|
else:
|
|
type_o = "E" if choice == "excusee" else "N"
|
|
statut = "excusee" if choice == "excusee" else "a_traiter"
|
|
if ab:
|
|
if ab.statut != statut:
|
|
old_type = ab.type_origine
|
|
ab.type_origine = type_o
|
|
ab.statut = statut
|
|
ab.updated_by = self.username
|
|
upsert_escada_pending(sess, self.selected_id, d, p, type_o)
|
|
nb_changes += 1
|
|
app_log(
|
|
f"[abs] {user} : {appr_label} — {d_str} P{p} : "
|
|
f"{old_type} → {type_o}"
|
|
)
|
|
else:
|
|
sess.add(Absence(
|
|
apprenti_id=self.selected_id,
|
|
date=d, periode=p,
|
|
type_origine=type_o, statut=statut,
|
|
updated_by=self.username, import_id=None,
|
|
))
|
|
upsert_escada_pending(sess, self.selected_id, d, p, type_o)
|
|
nb_changes += 1
|
|
app_log(
|
|
f"[abs] {user} : {appr_label} — {d_str} P{p} : "
|
|
f"présent → {type_o} (création)"
|
|
)
|
|
sess.commit()
|
|
self._reload(reset_email=False)
|
|
if nb_changes == 0:
|
|
return rx.toast.info("Aucune modification")
|
|
msg = (
|
|
f"{nb_changes} période modifiée pour {d_str}"
|
|
if nb_changes == 1
|
|
else f"{nb_changes} périodes modifiées pour {d_str}"
|
|
)
|
|
return rx.toast.success(msg)
|
|
|
|
# ── Quick excuse ──────────────────────────────────────────────────────────
|
|
def excuse_day(self, date_str: str):
|
|
sess = get_session()
|
|
d = date.fromisoformat(date_str)
|
|
apprenti = sess.get(Apprenti, self.selected_id)
|
|
appr_label = (
|
|
f"{apprenti.nom} {apprenti.prenom} ({apprenti.classe})"
|
|
if apprenti else f"id={self.selected_id}"
|
|
)
|
|
user = self.username or "?"
|
|
d_str = d.strftime("%d.%m.%Y")
|
|
absences = sess.execute(
|
|
select(Absence).where(
|
|
Absence.apprenti_id == self.selected_id,
|
|
Absence.date == d,
|
|
Absence.statut == "a_traiter",
|
|
)
|
|
).scalars().all()
|
|
nb = 0
|
|
for ab in absences:
|
|
old_type = ab.type_origine
|
|
ab.statut = "excusee"
|
|
ab.type_origine = "E"
|
|
ab.updated_by = self.username
|
|
upsert_escada_pending(sess, self.selected_id, d, ab.periode, "E")
|
|
nb += 1
|
|
app_log(
|
|
f"[abs] {user} : {appr_label} — {d_str} P{ab.periode} : "
|
|
f"{old_type} → E (excuse rapide)"
|
|
)
|
|
sess.commit()
|
|
if self.edit_date == date_str:
|
|
self.edit_date = ""
|
|
self._reload(reset_email=False)
|
|
if nb == 0:
|
|
return rx.toast.info("Aucune absence à excuser")
|
|
msg = (
|
|
f"1 période excusée pour {d_str}"
|
|
if nb == 1
|
|
else f"{nb} périodes excusées pour {d_str}"
|
|
)
|
|
return rx.toast.success(msg)
|
|
|
|
# ── Downloads ─────────────────────────────────────────────────────────────
|
|
def download_abs_pdf(self):
|
|
sess = get_session()
|
|
apprenti = sess.get(Apprenti, self.selected_id)
|
|
if apprenti is None:
|
|
return
|
|
pdf_bytes = _absence_pdf_apprenti(sess, apprenti)
|
|
filename = f"Absences_{apprenti.nom}_{apprenti.prenom}.pdf"
|
|
return rx.download(data=pdf_bytes, filename=filename)
|
|
|
|
def download_bn_pdf(self):
|
|
if not self.bn_pdf_fichier:
|
|
return
|
|
sess = get_session()
|
|
apprenti = sess.get(Apprenti, self.selected_id)
|
|
if apprenti is None:
|
|
return
|
|
pdf_path = DATA_DIR / "pdfs" / self.bn_pdf_fichier
|
|
pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
|
|
if pdf_bytes is None:
|
|
return
|
|
filename = f"BN_{apprenti.nom}_{apprenti.prenom}.pdf"
|
|
return rx.download(data=pdf_bytes, filename=filename)
|
|
|
|
def download_notes_pdf(self):
|
|
sess = get_session()
|
|
apprenti = sess.get(Apprenti, self.selected_id)
|
|
if apprenti is None:
|
|
return
|
|
notes_fname = f"notes_{apprenti.classe.replace(' ', '_')}.pdf"
|
|
pdf_path = DATA_DIR / "pdfs" / notes_fname
|
|
pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
|
|
if pdf_bytes is None:
|
|
return
|
|
filename = f"Notes_{apprenti.nom}_{apprenti.prenom}.pdf"
|
|
return rx.download(data=pdf_bytes, filename=filename)
|
|
|
|
# ── Email send (background task) ──────────────────────────────────────────
|
|
async def send_email_action(self):
|
|
from src.email_sender import send_email as _send_email
|
|
async with self:
|
|
self.email_sending = True
|
|
self.email_error = ""
|
|
self.email_sent = False
|
|
try:
|
|
s = _read_settings()
|
|
smtp_host = s.get("smtp_host", "smtp-relay.brevo.com")
|
|
smtp_port = int(s.get("smtp_port", 587))
|
|
smtp_login = s.get("smtp_login", "")
|
|
smtp_password = s.get("smtp_password", "")
|
|
smtp_sender = s.get("smtp_sender", "")
|
|
|
|
if self.email_dest == "apprenti":
|
|
recipients = [self.fiche_email_val] if self.fiche_email_val else []
|
|
elif self.email_dest == "formateur":
|
|
recipients = [self.fiche_formateur_email] if self.fiche_formateur_email else []
|
|
else:
|
|
recipients = [e.strip() for e in self.email_custom.split(",") if e.strip()]
|
|
|
|
if not recipients:
|
|
async with self:
|
|
self.email_error = "Aucune adresse email valide."
|
|
self.email_sending = False
|
|
return
|
|
|
|
sess = get_session()
|
|
apprenti = sess.get(Apprenti, self.selected_id)
|
|
attachments = []
|
|
|
|
if self.email_attach_abs and apprenti:
|
|
pdf_bytes = _absence_pdf_apprenti(sess, apprenti)
|
|
attachments.append((pdf_bytes, f"Absences_{apprenti.nom}_{apprenti.prenom}.pdf"))
|
|
|
|
if self.email_attach_bn and self.has_pdf_bn and apprenti:
|
|
pdf_path = DATA_DIR / "pdfs" / self.bn_pdf_fichier
|
|
pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
|
|
if pdf_bytes:
|
|
attachments.append((pdf_bytes, f"BN_{apprenti.nom}_{apprenti.prenom}.pdf"))
|
|
|
|
if self.email_attach_notes and self.has_pdf_notes and apprenti:
|
|
notes_fname = f"notes_{apprenti.classe.replace(' ', '_')}.pdf"
|
|
pdf_path = DATA_DIR / "pdfs" / notes_fname
|
|
pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
|
|
if pdf_bytes:
|
|
attachments.append((pdf_bytes, f"Notes_{apprenti.nom}_{apprenti.prenom}.pdf"))
|
|
|
|
if not attachments:
|
|
async with self:
|
|
self.email_error = "Sélectionnez au moins un document à joindre."
|
|
self.email_sending = False
|
|
return
|
|
|
|
errors = []
|
|
for to in recipients:
|
|
try:
|
|
_send_email(
|
|
smtp_host=smtp_host, smtp_port=smtp_port,
|
|
smtp_login=smtp_login, smtp_password=smtp_password,
|
|
smtp_sender=smtp_sender,
|
|
to_email=to, subject=self.email_subject, body=self.email_body,
|
|
attachments=attachments,
|
|
)
|
|
except Exception as e:
|
|
errors.append(f"{to}: {e}")
|
|
|
|
async with self:
|
|
if errors:
|
|
self.email_error = "; ".join(errors)
|
|
else:
|
|
self.email_sent = True
|
|
self.email_sending = False
|
|
except Exception as e:
|
|
async with self:
|
|
self.email_error = str(e)
|
|
self.email_sending = False
|
|
|
|
send_email_action._reflex_background_task = True
|
|
|
|
# ── Internal helpers ──────────────────────────────────────────────────────
|
|
def _reload(self, reset_email: bool = True):
|
|
sess = get_session()
|
|
absences = sess.execute(
|
|
select(Absence)
|
|
.where(Absence.apprenti_id == self.selected_id)
|
|
.order_by(Absence.date, Absence.periode)
|
|
).scalars().all()
|
|
|
|
self.kpi_total = len(absences)
|
|
self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee")
|
|
self.kpi_non_excusees = sum(1 for a in absences if a.statut == "a_traiter")
|
|
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
|
|
self.quota_atteint = self.kpi_blocs >= QUOTA
|
|
|
|
# Pending dates
|
|
by_date: dict = {}
|
|
for ab in absences:
|
|
by_date.setdefault(ab.date, []).append(ab)
|
|
self.pending_dates = [
|
|
{
|
|
"date_str": d.isoformat(),
|
|
"label": f"{d.strftime('%d.%m')} ({sum(1 for a in al if a.statut == 'a_traiter')})",
|
|
}
|
|
for d, al in sorted(by_date.items())
|
|
if any(a.statut == "a_traiter" for a in al)
|
|
]
|
|
|
|
# Fiche
|
|
fiche = sess.execute(
|
|
select(ApprentiFiche).where(ApprentiFiche.apprenti_id == self.selected_id)
|
|
).scalar_one_or_none()
|
|
if fiche:
|
|
self.fiche_available = True
|
|
self.fiche_adresse = fiche.adresse or ""
|
|
self.fiche_cp_localite = (
|
|
f"{fiche.code_postal or ''} {fiche.localite or ''}".strip()
|
|
)
|
|
self.fiche_telephone = fiche.telephone or ""
|
|
self.fiche_email_val = fiche.email or ""
|
|
self.fiche_date_naissance = fiche.date_naissance or ""
|
|
self.fiche_majeur = (
|
|
("Majeur : oui" if fiche.majeur else "Majeur : non")
|
|
if fiche.majeur is not None else ""
|
|
)
|
|
self.fiche_entreprise_nom = fiche.entreprise_nom or ""
|
|
self.fiche_entreprise_adresse = fiche.entreprise_adresse or ""
|
|
self.fiche_entreprise_cp_localite = (
|
|
f"{fiche.entreprise_code_postal or ''} "
|
|
f"{fiche.entreprise_localite or ''}".strip()
|
|
)
|
|
self.fiche_entreprise_telephone = fiche.entreprise_telephone or ""
|
|
self.fiche_entreprise_email = fiche.entreprise_email or ""
|
|
self.fiche_formateur_nom = fiche.formateur_nom or ""
|
|
self.fiche_formateur_email = fiche.formateur_email or ""
|
|
self.fiche_updated_at = (
|
|
fiche.updated_at.strftime("%d.%m.%Y %H:%M") if fiche.updated_at else ""
|
|
)
|
|
else:
|
|
self.fiche_available = False
|
|
for attr in [
|
|
"fiche_adresse", "fiche_cp_localite", "fiche_telephone",
|
|
"fiche_email_val", "fiche_date_naissance", "fiche_majeur",
|
|
"fiche_entreprise_nom", "fiche_entreprise_adresse",
|
|
"fiche_entreprise_cp_localite", "fiche_entreprise_telephone",
|
|
"fiche_entreprise_email", "fiche_formateur_nom",
|
|
"fiche_formateur_email", "fiche_updated_at",
|
|
]:
|
|
setattr(self, attr, "")
|
|
|
|
self._build_bn(sess)
|
|
|
|
if absences:
|
|
self.cal_year = absences[0].date.year
|
|
self.cal_month = absences[0].date.month
|
|
else:
|
|
today = date.today()
|
|
self.cal_year = today.year
|
|
self.cal_month = today.month
|
|
self._build_calendar_from(absences)
|
|
|
|
if reset_email:
|
|
s = _read_settings()
|
|
self.smtp_ok = bool(
|
|
s.get("smtp_host") and s.get("smtp_login") and s.get("smtp_password")
|
|
)
|
|
if self.smtp_ok:
|
|
apprenti = sess.get(Apprenti, self.selected_id)
|
|
if apprenti:
|
|
tvars = build_template_vars(apprenti, list(absences))
|
|
_def_subj = "Relevé d'absences — {nom_complet} ({classe})"
|
|
_def_body = (
|
|
"Bonjour {prenom},\n\n"
|
|
"Veuillez trouver ci-joint votre document.\n\n"
|
|
"Cordialement,\nL'équipe EPTM"
|
|
)
|
|
self.email_subject = render_template(
|
|
s.get("email_subject", _def_subj), tvars
|
|
)
|
|
self.email_body = render_template(
|
|
s.get("email_body", _def_body), tvars
|
|
)
|
|
self.email_sent = False
|
|
self.email_error = ""
|
|
self.email_dest = "apprenti"
|
|
self.email_custom = ""
|
|
self.email_attach_abs = True
|
|
self.email_attach_bn = False
|
|
self.email_attach_notes = False
|
|
|
|
def _build_bn(self, sess):
|
|
bn_records = sess.execute(
|
|
select(NotesBulletin, ImportBN)
|
|
.join(ImportBN, ImportBN.id == NotesBulletin.import_id)
|
|
.where(NotesBulletin.apprenti_id == self.selected_id)
|
|
.order_by(ImportBN.date_import.desc())
|
|
).all()
|
|
|
|
if not bn_records:
|
|
self.has_bn = False
|
|
self.bn_html = ""
|
|
self.bn_caption = ""
|
|
self.bn_pdf_fichier = ""
|
|
else:
|
|
bn, imp = bn_records[0]
|
|
sem_labels = json.loads(bn.sem_labels_json)
|
|
d = json.loads(bn.donnees_json)
|
|
groups_order = _GROUP_ORDER.get(bn.type_classe, ["BP"])
|
|
html = _bn_html_table(d, sem_labels, groups_order)
|
|
nm = sess.execute(
|
|
select(NotesMatu)
|
|
.join(ImportMatu, ImportMatu.id == NotesMatu.import_id)
|
|
.where(NotesMatu.apprenti_id == self.selected_id)
|
|
.order_by(ImportMatu.date_import.desc())
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if nm:
|
|
html += _matu_html_table(nm)
|
|
self.has_bn = True
|
|
self.bn_html = html
|
|
self.bn_caption = (
|
|
f"Import du {imp.date_import.strftime('%d.%m.%Y %H:%M')} — {imp.imported_by}"
|
|
)
|
|
self.bn_pdf_fichier = imp.fichier or ""
|
|
|
|
ne_rec = sess.execute(
|
|
select(NotesExamen).where(NotesExamen.apprenti_id == self.selected_id)
|
|
).scalar_one_or_none()
|
|
if ne_rec:
|
|
notes_data = json.loads(ne_rec.donnees_json)
|
|
if notes_data:
|
|
self.has_notes = True
|
|
self.notes_html = _render_notes_html(notes_data)
|
|
else:
|
|
self.has_notes = False
|
|
self.notes_html = ""
|
|
else:
|
|
self.has_notes = False
|
|
self.notes_html = ""
|
|
|
|
pdf_dir = DATA_DIR / "pdfs"
|
|
self.has_pdf_bn = bool(self.bn_pdf_fichier) and (pdf_dir / self.bn_pdf_fichier).exists()
|
|
apprenti = sess.get(Apprenti, self.selected_id)
|
|
if apprenti:
|
|
notes_pdf_fname = f"notes_{apprenti.classe.replace(' ', '_')}.pdf"
|
|
self.has_pdf_notes = (pdf_dir / notes_pdf_fname).exists()
|
|
else:
|
|
self.has_pdf_notes = False
|
|
|
|
def _rebuild_calendar(self):
|
|
sess = get_session()
|
|
absences = sess.execute(
|
|
select(Absence)
|
|
.where(Absence.apprenti_id == self.selected_id)
|
|
.order_by(Absence.date, Absence.periode)
|
|
).scalars().all()
|
|
self._build_calendar_from(absences)
|
|
|
|
def _build_calendar_from(self, absences):
|
|
self.cal_month_name = (
|
|
f"{MOIS_FR[self.cal_month - 1].capitalize()} {self.cal_year}"
|
|
)
|
|
prev_m = self.cal_month - 1 if self.cal_month > 1 else 12
|
|
self.cal_prev_name = MOIS_FR[prev_m - 1].capitalize()
|
|
next_m = self.cal_month + 1 if self.cal_month < 12 else 1
|
|
self.cal_next_name = MOIS_FR[next_m - 1].capitalize()
|
|
|
|
abs_by_date: dict = {}
|
|
for ab in absences:
|
|
if ab.date.year == self.cal_year and ab.date.month == self.cal_month:
|
|
key = ab.date.isoformat()
|
|
if key not in abs_by_date:
|
|
abs_by_date[key] = {"excusees": 0, "non_excusees": 0}
|
|
if ab.statut == "excusee":
|
|
abs_by_date[key]["excusees"] += 1
|
|
else:
|
|
abs_by_date[key]["non_excusees"] += 1
|
|
|
|
today = date.today()
|
|
weeks = calendar.monthcalendar(self.cal_year, self.cal_month)
|
|
days: list[dict] = []
|
|
for week in weeks:
|
|
for day_num in week:
|
|
if day_num == 0:
|
|
days.append({
|
|
"day": 0, "is_empty": True, "has_abs": False,
|
|
"has_non_exc": False, "excusees": 0, "non_excusees": 0,
|
|
"is_today": False, "date_str": "", "label": "",
|
|
})
|
|
else:
|
|
d = date(self.cal_year, self.cal_month, day_num)
|
|
key = d.isoformat()
|
|
info = abs_by_date.get(key, {})
|
|
has_abs = key in abs_by_date
|
|
exc = info.get("excusees", 0)
|
|
non = info.get("non_excusees", 0)
|
|
has_non_exc = non > 0
|
|
if has_abs:
|
|
parts = [str(day_num)]
|
|
if non > 0:
|
|
parts.append(f"⚠{non}")
|
|
if exc > 0:
|
|
parts.append(f"✓{exc}")
|
|
lbl = " ".join(parts)
|
|
else:
|
|
lbl = str(day_num)
|
|
days.append({
|
|
"day": day_num, "is_empty": False, "has_abs": has_abs,
|
|
"has_non_exc": has_non_exc, "excusees": exc, "non_excusees": non,
|
|
"is_today": d == today, "date_str": key, "label": lbl,
|
|
})
|
|
self.cal_days = days
|
|
|
|
|
|
# ── UI components ─────────────────────────────────────────────────────────────
|
|
|
|
def _apprenti_option(label: rx.Var) -> rx.Component:
|
|
return rx.box(
|
|
rx.text(label, size="2"),
|
|
padding="0.45rem 0.75rem",
|
|
cursor="pointer",
|
|
on_click=FicheState.handle_select(label),
|
|
_hover={"background_color": "var(--gray-3)"},
|
|
width="100%",
|
|
)
|
|
|
|
|
|
def _apprenti_searchable_select() -> rx.Component:
|
|
return rx.popover.root(
|
|
rx.popover.trigger(
|
|
rx.box(
|
|
rx.flex(
|
|
rx.cond(
|
|
FicheState.selected_label != "",
|
|
rx.text(FicheState.selected_label, size="2"),
|
|
rx.text("Sélectionner un apprenti…", size="2", color="var(--gray-9)"),
|
|
),
|
|
rx.spacer(),
|
|
rx.icon("chevron-down", size=18, color="var(--gray-9)"),
|
|
align="center",
|
|
width="100%",
|
|
),
|
|
padding="0.5rem 0.75rem",
|
|
border="1px solid var(--gray-7)",
|
|
border_radius="6px",
|
|
background_color="white",
|
|
cursor="pointer",
|
|
width="100%",
|
|
custom_attrs={"data-shortcut": "apprenti-search"},
|
|
),
|
|
),
|
|
rx.popover.content(
|
|
rx.vstack(
|
|
rx.input(
|
|
placeholder="Rechercher un apprenti…",
|
|
value=FicheState.apprenti_search,
|
|
on_change=FicheState.set_apprenti_search,
|
|
on_key_down=FicheState.apprenti_search_keydown,
|
|
size="2",
|
|
width="100%",
|
|
auto_focus=True,
|
|
),
|
|
rx.cond(
|
|
FicheState.filtered_apprenti_labels.length() > 0,
|
|
rx.box(
|
|
rx.foreach(FicheState.filtered_apprenti_labels, _apprenti_option),
|
|
max_height="280px",
|
|
overflow_y="auto",
|
|
width="100%",
|
|
),
|
|
rx.box(
|
|
rx.text("Aucun résultat", size="2", color="var(--gray-9)"),
|
|
padding="0.5rem 0.75rem",
|
|
),
|
|
),
|
|
spacing="2",
|
|
width="100%",
|
|
),
|
|
min_width="320px",
|
|
max_width="500px",
|
|
padding="0.5rem",
|
|
),
|
|
open=FicheState.apprenti_select_open,
|
|
on_open_change=FicheState.set_apprenti_select_open,
|
|
)
|
|
|
|
|
|
def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
|
|
return rx.box(
|
|
rx.text(label, size="1", color="#666"),
|
|
rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"),
|
|
padding="1rem",
|
|
background_color="white",
|
|
border_radius="8px",
|
|
border="1px solid #e0e0e0",
|
|
flex="1",
|
|
min_width="120px",
|
|
class_name="hover-lift",
|
|
)
|
|
|
|
|
|
def _info_line(icon: str, value) -> rx.Component:
|
|
return rx.cond(
|
|
value != "",
|
|
rx.hstack(
|
|
rx.icon(icon, size=14, color="#9e9e9e"),
|
|
rx.text(value, size="2", color="#555"),
|
|
spacing="2",
|
|
align="center",
|
|
),
|
|
)
|
|
|
|
|
|
def _cal_day_cell(d) -> rx.Component:
|
|
is_selected = d["date_str"] == FicheState.edit_date
|
|
return rx.cond(
|
|
d["is_empty"],
|
|
rx.box(min_height="36px", border_radius="4px"),
|
|
rx.box(
|
|
rx.text(
|
|
d["label"],
|
|
size="1",
|
|
font_weight=rx.cond(d["is_today"], "700", "400"),
|
|
color=rx.cond(
|
|
is_selected, "#1565c0",
|
|
rx.cond(
|
|
d["has_non_exc"], "#c62828",
|
|
rx.cond(d["has_abs"], "#2e7d32", "#333"),
|
|
),
|
|
),
|
|
text_align="center",
|
|
),
|
|
min_height="36px",
|
|
border_radius="4px",
|
|
background_color=rx.cond(
|
|
is_selected, "#dbeafe",
|
|
rx.cond(
|
|
d["has_non_exc"], "#ffebee",
|
|
rx.cond(
|
|
d["has_abs"], "#e8f5e9",
|
|
rx.cond(d["is_today"], "#e3f2fd", "white"),
|
|
),
|
|
),
|
|
),
|
|
border=rx.cond(
|
|
is_selected, "2px solid #1565c0",
|
|
rx.cond(d["is_today"], "2px solid #90caf9", "1px solid #eee"),
|
|
),
|
|
display="flex",
|
|
align_items="center",
|
|
justify_content="center",
|
|
cursor=rx.cond(d["has_abs"], "pointer", "default"),
|
|
on_click=FicheState.select_day(d["date_str"]),
|
|
class_name="smooth-transition",
|
|
_hover=rx.cond(
|
|
d["has_abs"],
|
|
{"transform": "scale(1.05)", "box_shadow": "0 2px 6px rgba(0,0,0,0.1)"},
|
|
{},
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def _period_select(p_num: int, val, setter) -> rx.Component:
|
|
return rx.hstack(
|
|
rx.text(f"P{p_num}", size="2", weight="medium", color="#555",
|
|
min_width="28px", text_align="right"),
|
|
rx.segmented_control.root(
|
|
rx.segmented_control.item("Présent", value="present"),
|
|
rx.segmented_control.item("E", value="excusee"),
|
|
rx.segmented_control.item("N", value="non_excusee"),
|
|
value=val,
|
|
on_change=setter,
|
|
size="1",
|
|
color_scheme=rx.match(
|
|
val,
|
|
("excusee", "orange"),
|
|
("non_excusee", "red"),
|
|
"gray",
|
|
),
|
|
radius="medium",
|
|
),
|
|
spacing="2",
|
|
align="center",
|
|
)
|
|
|
|
|
|
def _edit_panel() -> rx.Component:
|
|
return rx.box(
|
|
rx.vstack(
|
|
rx.hstack(
|
|
rx.icon("pencil", size=15, color="#1565c0"),
|
|
rx.text(
|
|
"Édition du ", FicheState.edit_date_label,
|
|
size="3", weight="bold", color="#37474f",
|
|
),
|
|
rx.spacer(),
|
|
rx.button(
|
|
rx.icon("x", size=14),
|
|
on_click=FicheState.cancel_edit,
|
|
variant="ghost", color_scheme="gray", size="1",
|
|
),
|
|
width="100%", align="center",
|
|
),
|
|
rx.divider(),
|
|
rx.flex(
|
|
rx.vstack(
|
|
_period_select(1, FicheState.edit_p1, FicheState.set_edit_p1),
|
|
_period_select(2, FicheState.edit_p2, FicheState.set_edit_p2),
|
|
_period_select(3, FicheState.edit_p3, FicheState.set_edit_p3),
|
|
_period_select(4, FicheState.edit_p4, FicheState.set_edit_p4),
|
|
_period_select(5, FicheState.edit_p5, FicheState.set_edit_p5),
|
|
spacing="2", flex="1 1 240px", min_width="240px",
|
|
),
|
|
rx.vstack(
|
|
_period_select(6, FicheState.edit_p6, FicheState.set_edit_p6),
|
|
_period_select(7, FicheState.edit_p7, FicheState.set_edit_p7),
|
|
_period_select(8, FicheState.edit_p8, FicheState.set_edit_p8),
|
|
_period_select(9, FicheState.edit_p9, FicheState.set_edit_p9),
|
|
_period_select(10, FicheState.edit_p10, FicheState.set_edit_p10),
|
|
spacing="2", flex="1 1 240px", min_width="240px",
|
|
),
|
|
gap="0.6rem",
|
|
flex_wrap="wrap",
|
|
width="100%",
|
|
),
|
|
rx.hstack(
|
|
rx.button(
|
|
rx.icon("save", size=14), "Enregistrer",
|
|
on_click=FicheState.save_day_edit,
|
|
color_scheme="blue", size="2",
|
|
),
|
|
rx.button(
|
|
"Annuler",
|
|
on_click=FicheState.cancel_edit,
|
|
variant="outline", color_scheme="gray", size="2",
|
|
),
|
|
spacing="3",
|
|
),
|
|
spacing="3", width="100%",
|
|
),
|
|
padding="1rem",
|
|
background_color="#f0f7ff",
|
|
border_radius="8px",
|
|
border="1px solid #bfdbfe",
|
|
width="100%",
|
|
class_name="anim-slide-down",
|
|
)
|
|
|
|
|
|
def _pending_btn(item: dict) -> rx.Component:
|
|
return rx.button(
|
|
rx.icon("check", size=13),
|
|
item["label"],
|
|
on_click=FicheState.excuse_day(item["date_str"]),
|
|
color_scheme="green",
|
|
variant="soft",
|
|
size="1",
|
|
)
|
|
|
|
|
|
def _email_section() -> rx.Component:
|
|
return rx.box(
|
|
rx.vstack(
|
|
rx.hstack(
|
|
rx.icon("mail", size=16, color="#37474f"),
|
|
rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"),
|
|
spacing="2", align="center",
|
|
),
|
|
rx.divider(),
|
|
rx.flex(
|
|
# ── Left: destinataire + pièces jointes ──────────────────────
|
|
rx.vstack(
|
|
rx.text("Destinataire", size="2", weight="bold", color="#555"),
|
|
rx.radio_group.root(
|
|
rx.vstack(
|
|
rx.radio_group.item(
|
|
rx.cond(
|
|
FicheState.fiche_email_val != "",
|
|
rx.text("Apprenti — ", FicheState.fiche_email_val, size="2"),
|
|
rx.text("Apprenti (email inconnu)", size="2", color="#999"),
|
|
),
|
|
value="apprenti",
|
|
disabled=FicheState.fiche_email_val == "",
|
|
),
|
|
rx.radio_group.item(
|
|
rx.cond(
|
|
FicheState.fiche_formateur_email != "",
|
|
rx.text("Formateur — ", FicheState.fiche_formateur_email, size="2"),
|
|
rx.text("Formateur (email inconnu)", size="2", color="#999"),
|
|
),
|
|
value="formateur",
|
|
disabled=FicheState.fiche_formateur_email == "",
|
|
),
|
|
rx.radio_group.item(
|
|
rx.text("Autre adresse", size="2"),
|
|
value="autre",
|
|
),
|
|
spacing="2",
|
|
),
|
|
value=FicheState.email_dest,
|
|
on_change=FicheState.set_email_dest,
|
|
),
|
|
rx.cond(
|
|
FicheState.email_dest == "autre",
|
|
rx.input(
|
|
value=FicheState.email_custom,
|
|
on_change=FicheState.set_email_custom,
|
|
placeholder="email1@ex.com, email2@ex.com",
|
|
size="1", width="100%",
|
|
),
|
|
),
|
|
rx.text("Pièces jointes", size="2", weight="bold",
|
|
color="#555", margin_top="0.5rem"),
|
|
rx.vstack(
|
|
rx.checkbox(
|
|
"Tableau des absences",
|
|
checked=FicheState.email_attach_abs,
|
|
on_change=FicheState.toggle_attach_abs,
|
|
size="1",
|
|
),
|
|
rx.checkbox(
|
|
rx.cond(
|
|
FicheState.has_pdf_bn,
|
|
"Bulletin de notes",
|
|
"Bulletin de notes (indisponible)",
|
|
),
|
|
checked=FicheState.email_attach_bn,
|
|
on_change=FicheState.toggle_attach_bn,
|
|
disabled=~FicheState.has_pdf_bn,
|
|
size="1",
|
|
),
|
|
rx.checkbox(
|
|
rx.cond(
|
|
FicheState.has_pdf_notes,
|
|
"Notes d'examen",
|
|
"Notes d'examen (indisponible)",
|
|
),
|
|
checked=FicheState.email_attach_notes,
|
|
on_change=FicheState.toggle_attach_notes,
|
|
disabled=~FicheState.has_pdf_notes,
|
|
size="1",
|
|
),
|
|
spacing="2",
|
|
),
|
|
spacing="2", flex="1", min_width="220px",
|
|
),
|
|
# ── Right: message + envoyer ──────────────────────────────────
|
|
rx.vstack(
|
|
rx.vstack(
|
|
rx.text("Objet", size="2", weight="bold", color="#555"),
|
|
rx.input(
|
|
value=FicheState.email_subject,
|
|
on_change=FicheState.set_email_subject,
|
|
width="100%", size="1",
|
|
),
|
|
spacing="1", width="100%",
|
|
),
|
|
rx.vstack(
|
|
rx.text("Corps du message", size="2", weight="bold", color="#555"),
|
|
rx.text_area(
|
|
value=FicheState.email_body,
|
|
on_change=FicheState.set_email_body,
|
|
rows="7", width="100%",
|
|
font_family="monospace",
|
|
font_size="0.82rem",
|
|
resize="vertical",
|
|
),
|
|
spacing="1", width="100%",
|
|
),
|
|
rx.button(
|
|
rx.cond(
|
|
FicheState.email_sending,
|
|
rx.hstack(
|
|
rx.spinner(size="1"),
|
|
rx.text("Envoi en cours…"),
|
|
spacing="2", align="center",
|
|
),
|
|
rx.hstack(
|
|
rx.icon("send", size=14),
|
|
rx.text("Envoyer"),
|
|
spacing="2", align="center",
|
|
),
|
|
),
|
|
on_click=FicheState.send_email_action,
|
|
color_scheme="blue", size="2", width="100%",
|
|
disabled=FicheState.email_sending,
|
|
),
|
|
rx.cond(
|
|
FicheState.email_sent,
|
|
rx.callout.root(
|
|
rx.callout.icon(rx.icon("check", size=14)),
|
|
rx.callout.text("Email envoyé."),
|
|
color_scheme="green", variant="soft", size="1",
|
|
),
|
|
),
|
|
rx.cond(
|
|
FicheState.email_error != "",
|
|
rx.callout.root(
|
|
rx.callout.icon(rx.icon("triangle-alert", size=14)),
|
|
rx.callout.text(FicheState.email_error),
|
|
color_scheme="red", variant="soft", size="1",
|
|
),
|
|
),
|
|
spacing="3", flex="2", min_width="260px",
|
|
),
|
|
gap="1.5rem", flex_wrap="wrap", width="100%", align="start",
|
|
),
|
|
spacing="3", width="100%",
|
|
),
|
|
padding="1rem",
|
|
background_color="white",
|
|
border_radius="8px",
|
|
border="1px solid #e0e0e0",
|
|
width="100%",
|
|
)
|
|
|
|
|
|
_DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
|
|
|
|
|
|
def fiche_page() -> rx.Component:
|
|
return layout(
|
|
rx.vstack(
|
|
rx.heading("Fiche apprenti", size="7"),
|
|
|
|
rx.cond(
|
|
FicheState.has_apprentis,
|
|
rx.vstack(
|
|
|
|
# ── Sélecteur apprenti (recherche intégrée) ───────────────
|
|
_apprenti_searchable_select(),
|
|
|
|
# ── KPI cards ─────────────────────────────────────────────
|
|
rx.flex(
|
|
_kpi_card("Périodes d'absence", FicheState.kpi_total),
|
|
_kpi_card("Périodes à excuser", FicheState.kpi_non_excusees, "#c62828"),
|
|
rx.box(
|
|
rx.text("Absences", size="1", color="#666"),
|
|
rx.text(
|
|
FicheState.kpi_blocs,
|
|
size="7", font_weight="700",
|
|
color=rx.cond(FicheState.quota_atteint, "#c62828", "#37474f"),
|
|
),
|
|
rx.cond(
|
|
FicheState.quota_atteint,
|
|
rx.text(
|
|
"Avis de sanction",
|
|
size="1", weight="bold", color="#c62828",
|
|
),
|
|
),
|
|
padding="1rem",
|
|
background_color=rx.cond(FicheState.quota_atteint, "#fff0f0", "white"),
|
|
border_radius="8px",
|
|
border=rx.cond(
|
|
FicheState.quota_atteint,
|
|
"1px solid #ffcdd2",
|
|
"1px solid #e0e0e0",
|
|
),
|
|
flex="1",
|
|
min_width="120px",
|
|
),
|
|
gap="1rem", flex_wrap="wrap", width="100%",
|
|
),
|
|
|
|
# ── Fiche détaillée Escada ────────────────────────────────
|
|
rx.box(
|
|
rx.cond(
|
|
FicheState.fiche_available,
|
|
rx.vstack(
|
|
rx.flex(
|
|
rx.vstack(
|
|
rx.text("Élève", size="2", font_weight="700", color="#37474f"),
|
|
_info_line("map-pin", FicheState.fiche_adresse),
|
|
_info_line("map-pin", FicheState.fiche_cp_localite),
|
|
_info_line("phone", FicheState.fiche_telephone),
|
|
_info_line("mail", FicheState.fiche_email_val),
|
|
_info_line("cake", FicheState.fiche_date_naissance),
|
|
_info_line("user-check", FicheState.fiche_majeur),
|
|
spacing="1", align="start", flex="1", min_width="200px",
|
|
),
|
|
rx.vstack(
|
|
rx.text("Entreprise", size="2", font_weight="700", color="#37474f"),
|
|
_info_line("building-2", FicheState.fiche_entreprise_nom),
|
|
_info_line("map-pin", FicheState.fiche_entreprise_adresse),
|
|
_info_line("map-pin", FicheState.fiche_entreprise_cp_localite),
|
|
_info_line("phone", FicheState.fiche_entreprise_telephone),
|
|
_info_line("mail", FicheState.fiche_entreprise_email),
|
|
spacing="1", align="start", flex="1", min_width="200px",
|
|
),
|
|
rx.vstack(
|
|
rx.text("Formateur", size="2", font_weight="700", color="#37474f"),
|
|
_info_line("user", FicheState.fiche_formateur_nom),
|
|
_info_line("mail", FicheState.fiche_formateur_email),
|
|
spacing="1", align="start", flex="1", min_width="200px",
|
|
),
|
|
gap="1.5rem", flex_wrap="wrap", width="100%",
|
|
),
|
|
rx.text(
|
|
"Mis à jour le ", FicheState.fiche_updated_at, " depuis Escada",
|
|
size="1", color="#9e9e9e", margin_top="0.5rem",
|
|
),
|
|
spacing="3", width="100%",
|
|
),
|
|
rx.text(
|
|
"Aucune fiche disponible. Lancez une synchronisation Escada.",
|
|
size="2", color="#666",
|
|
),
|
|
),
|
|
padding="1rem",
|
|
background_color="white",
|
|
border_radius="8px",
|
|
border="1px solid #e0e0e0",
|
|
width="100%",
|
|
),
|
|
|
|
# ── Bulletin de notes ─────────────────────────────────────
|
|
rx.box(
|
|
rx.tabs.root(
|
|
rx.tabs.list(
|
|
rx.tabs.trigger("Cours professionnels", value="bn"),
|
|
rx.tabs.trigger("Notes d'examen", value="notes"),
|
|
),
|
|
rx.tabs.content(
|
|
rx.cond(
|
|
FicheState.has_bn,
|
|
rx.vstack(
|
|
rx.text(FicheState.bn_caption, size="1", color="#9e9e9e"),
|
|
rx.box(
|
|
rx.html(FicheState.bn_html),
|
|
width="100%",
|
|
max_width="100%",
|
|
overflow_x="auto",
|
|
),
|
|
spacing="2", width="100%",
|
|
),
|
|
rx.text(
|
|
"Aucun bulletin de notes importé pour cet(te) apprenti(e).",
|
|
size="2", color="#666",
|
|
),
|
|
),
|
|
value="bn", width="100%", padding_top="1rem",
|
|
),
|
|
rx.tabs.content(
|
|
rx.cond(
|
|
FicheState.has_notes,
|
|
rx.html(FicheState.notes_html),
|
|
rx.text(
|
|
"Aucune note d'examen disponible. Lancez une synchronisation Escada avec l'option Notes.",
|
|
size="2", color="#666",
|
|
),
|
|
),
|
|
value="notes", width="100%", padding_top="1rem",
|
|
),
|
|
default_value="bn", width="100%",
|
|
),
|
|
padding="1rem",
|
|
background_color="white",
|
|
border_radius="8px",
|
|
border="1px solid #e0e0e0",
|
|
width="100%",
|
|
),
|
|
|
|
# ── Export PDF ────────────────────────────────────────────
|
|
rx.flex(
|
|
rx.button(
|
|
rx.icon("download", size=13), "PDF absences",
|
|
on_click=FicheState.download_abs_pdf,
|
|
variant="outline", color_scheme="gray", size="1",
|
|
),
|
|
rx.cond(
|
|
FicheState.has_pdf_bn,
|
|
rx.button(
|
|
rx.icon("file-text", size=13), "PDF bulletin",
|
|
on_click=FicheState.download_bn_pdf,
|
|
variant="outline", color_scheme="blue", size="1",
|
|
),
|
|
),
|
|
rx.cond(
|
|
FicheState.has_pdf_notes,
|
|
rx.button(
|
|
rx.icon("file-text", size=13), "PDF notes",
|
|
on_click=FicheState.download_notes_pdf,
|
|
variant="outline", color_scheme="violet", size="1",
|
|
),
|
|
),
|
|
flex_wrap="wrap", gap="0.5rem",
|
|
),
|
|
|
|
# ── Calendrier mensuel ────────────────────────────────────
|
|
rx.cond(
|
|
FicheState.kpi_total > 0,
|
|
rx.box(
|
|
rx.hstack(
|
|
rx.button(
|
|
rx.icon("chevron-left", size=14), FicheState.cal_prev_name,
|
|
on_click=FicheState.prev_month,
|
|
variant="outline", color_scheme="gray", size="2",
|
|
),
|
|
rx.text(
|
|
FicheState.cal_month_name,
|
|
size="4", font_weight="700", color="#37474f",
|
|
flex="1", text_align="center",
|
|
),
|
|
rx.button(
|
|
FicheState.cal_next_name, rx.icon("chevron-right", size=14),
|
|
on_click=FicheState.next_month,
|
|
variant="outline", color_scheme="gray", size="2",
|
|
),
|
|
width="100%", align="center", margin_bottom="0.5rem",
|
|
),
|
|
rx.grid(
|
|
*[
|
|
rx.text(h, size="1", color="#9e9e9e",
|
|
text_align="center", font_weight="600")
|
|
for h in _DOW
|
|
],
|
|
columns="7", gap="2px", width="100%", margin_bottom="2px",
|
|
),
|
|
rx.grid(
|
|
rx.foreach(FicheState.cal_days, _cal_day_cell),
|
|
columns="7", gap="2px", width="100%",
|
|
),
|
|
rx.hstack(
|
|
rx.box(width="12px", height="12px", background_color="#ffebee",
|
|
border_radius="2px", border="1px solid #eee"),
|
|
rx.text("Non excusée", size="1", color="#666"),
|
|
rx.box(width="12px", height="12px", background_color="#e8f5e9",
|
|
border_radius="2px", border="1px solid #eee"),
|
|
rx.text("Excusée", size="1", color="#666"),
|
|
rx.box(width="12px", height="12px", background_color="#dbeafe",
|
|
border_radius="2px", border="2px solid #1565c0"),
|
|
rx.text("Sélectionné", size="1", color="#666"),
|
|
spacing="2", align="center", margin_top="0.5rem",
|
|
),
|
|
rx.text(
|
|
"Cliquez sur un jour avec absences pour éditer les périodes.",
|
|
size="1", color="#9e9e9e", margin_top="0.25rem",
|
|
),
|
|
padding="1rem",
|
|
background_color="white",
|
|
border_radius="8px",
|
|
border="1px solid #e0e0e0",
|
|
width="100%",
|
|
),
|
|
rx.text("Aucune absence enregistrée.", size="2", color="#666"),
|
|
),
|
|
|
|
# ── Actions rapides ───────────────────────────────────────
|
|
rx.cond(
|
|
FicheState.pending_dates.length() > 0,
|
|
rx.box(
|
|
rx.vstack(
|
|
rx.hstack(
|
|
rx.icon("clock", size=15, color="#b45309"),
|
|
rx.text(
|
|
"Valider toutes les absences d'une journée",
|
|
size="2", weight="bold", color="#92400e",
|
|
),
|
|
spacing="2", align="center",
|
|
),
|
|
rx.flex(
|
|
rx.foreach(FicheState.pending_dates, _pending_btn),
|
|
flex_wrap="wrap", gap="0.5rem",
|
|
),
|
|
spacing="2", width="100%",
|
|
),
|
|
padding="0.75rem 1rem",
|
|
background_color="#fffbeb",
|
|
border_radius="8px",
|
|
border="1px solid #fcd34d",
|
|
width="100%",
|
|
),
|
|
),
|
|
|
|
# ── Panneau d'édition ─────────────────────────────────────
|
|
rx.cond(
|
|
FicheState.edit_date != "",
|
|
_edit_panel(),
|
|
),
|
|
|
|
# ── Email ─────────────────────────────────────────────────
|
|
rx.cond(
|
|
FicheState.smtp_ok,
|
|
_email_section(),
|
|
rx.box(
|
|
rx.hstack(
|
|
rx.icon("mail", size=14, color="#9e9e9e"),
|
|
rx.text(
|
|
"Email non configuré. Rendez-vous dans Paramètres.",
|
|
size="2", color="#9e9e9e",
|
|
),
|
|
spacing="2", align="center",
|
|
),
|
|
padding="0.75rem 1rem",
|
|
background_color="#f9fafb",
|
|
border_radius="8px",
|
|
border="1px solid #e5e7eb",
|
|
width="100%",
|
|
),
|
|
),
|
|
|
|
spacing="4", width="100%",
|
|
),
|
|
empty_state(
|
|
icon="users",
|
|
title="Aucun apprenti",
|
|
description="Importe les classes depuis Escadaweb pour commencer à gérer les absences.",
|
|
action_label="Lancer un import",
|
|
action_href="/escada",
|
|
),
|
|
),
|
|
|
|
spacing="5", width="100%",
|
|
)
|
|
)
|