diff --git a/assets/favicon.png b/assets/favicon.png new file mode 100644 index 0000000..e13d8e5 Binary files /dev/null and b/assets/favicon.png differ diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..becf5e5 --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + E + + + + + + + + + + + EPTM + diff --git a/assets/responsive.css b/assets/responsive.css new file mode 100644 index 0000000..4c9cfb2 --- /dev/null +++ b/assets/responsive.css @@ -0,0 +1,64 @@ +/* Reset default margins and padding */ +* { + box-sizing: border-box; +} + +body, html { + width: 100%; + max-width: 100%; + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; +} + +#root, #__next { + height: 100%; +} + +/* App shell: viewport-bounded with internal scroll on content */ +.content-area { + width: 100%; + max-width: 100%; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; +} + +/* Mobile: hide desktop sidebar, account for fixed topbar (56px) */ +@media (max-width: 767px) { + .sidebar-desktop { display: none !important; } + .content-area { + margin-left: 0 !important; + width: 100% !important; + padding-top: calc(56px + 0.75rem) !important; + padding-left: 0.75rem !important; + padding-right: 0.75rem !important; + padding-bottom: 0.75rem !important; + } +} + +/* Tablet */ +@media (min-width: 768px) and (max-width: 1024px) { + .content-area { + padding: 1rem !important; + } +} + +/* Desktop: hide mobile topbar */ +@media (min-width: 768px) { + .topbar-mobile { display: none !important; } +} + +/* Ensure responsive images and content */ +img { + max-width: 100%; + height: auto; + display: block; +} + +/* Ensure flex containers wrap properly */ +.content-area > * { + min-width: 0; + max-width: 100%; +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3ba05cf..22a1acd 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,8 +9,10 @@ services: - "8001:8001" volumes: - ./eptm_dashboard:/app/eptm_dashboard + - ./rxconfig.py:/app/rxconfig.py - ./data:/data - ./logs:/logs + - ./assets:/app/assets env_file: - .env.prod environment: diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py index 36f97cb..735afce 100644 --- a/eptm_dashboard/eptm_dashboard.py +++ b/eptm_dashboard/eptm_dashboard.py @@ -12,16 +12,23 @@ from .pages.logs import logs_page from .pages.users import users_page from .pages.params import params_page -app = rx.App() +TITLE = "EPTM Dashboard" -app.add_page(login_page, route="/login") -app.add_page(accueil_page, route="/accueil", on_load=AccueilState.load_data) -app.add_page(traiter_page, route="/traiter", on_load=AuthState.check_auth) -app.add_page(fiche_page, route="/fiche", on_load=FicheState.load_data) -app.add_page(classe_page, route="/classe", on_load=AuthState.check_auth) -app.add_page(import_page_page, route="/import", on_load=AuthState.check_auth) -app.add_page(escada_page, route="/escada", on_load=AuthState.check_auth) -app.add_page(export_page, route="/export", on_load=AuthState.check_auth) -app.add_page(logs_page, route="/logs", on_load=AuthState.check_auth) -app.add_page(users_page, route="/users", on_load=AuthState.check_auth) -app.add_page(params_page, route="/params", on_load=AuthState.check_auth) +app = rx.App( + stylesheets=["/responsive.css"], + head_components=[ + rx.el.link(rel="icon", type="image/png", href="/favicon.png"), + ], +) + +app.add_page(login_page, route="/login", title=TITLE) +app.add_page(accueil_page, route="/accueil", on_load=AccueilState.load_data, title=TITLE) +app.add_page(traiter_page, route="/traiter", on_load=AuthState.check_auth, title=TITLE) +app.add_page(fiche_page, route="/fiche", on_load=FicheState.load_data, title=TITLE) +app.add_page(classe_page, route="/classe", on_load=AuthState.check_auth, title=TITLE) +app.add_page(import_page_page, route="/import", on_load=AuthState.check_auth, title=TITLE) +app.add_page(escada_page, route="/escada", on_load=AuthState.check_auth, title=TITLE) +app.add_page(export_page, route="/export", on_load=AuthState.check_auth, title=TITLE) +app.add_page(logs_page, route="/logs", on_load=AuthState.check_auth, title=TITLE) +app.add_page(users_page, route="/users", on_load=AuthState.check_auth, title=TITLE) +app.add_page(params_page, route="/params", on_load=AuthState.check_auth, title=TITLE) diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py index 32a5611..aad777d 100644 --- a/eptm_dashboard/pages/accueil.py +++ b/eptm_dashboard/pages/accueil.py @@ -52,7 +52,8 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component: border_radius="8px", padding="0.75rem 1rem", flex="1", - min_width="120px", + min_width="80px", + width="100%", ) @@ -105,36 +106,41 @@ def accueil_page() -> rx.Component: spacing="3", width="100%", wrap="wrap", + align_items="stretch", ), rx.divider(), rx.heading("🚨 Avis de sanction — quota atteint", size="5"), - rx.cond( - AccueilState.sanctions.length() == 0, - rx.box( - rx.text("✓ Aucun apprenti n'a atteint le quota de 5 absences.", - color="#2e7d32", size="2"), - background_color="#f1f8f1", - border="1px solid #c8e6c9", - border_radius="6px", - padding="0.75rem 1rem", - ), - rx.vstack( + rx.box( + rx.cond( + AccueilState.sanctions.length() == 0, rx.box( - rx.text("⚠ ", AccueilState.sanctions.length(), - " apprenti(s) en avis de sanction", - color="#B71C1C", size="2", font_weight="600"), - background_color="#fff5f5", - border="1px solid #ffcccc", + rx.text("✓ Aucun apprenti n'a atteint le quota de 5 absences.", + color="#2e7d32", size="2"), + background_color="#f1f8f1", + border="1px solid #c8e6c9", border_radius="6px", padding="0.75rem 1rem", width="100%", ), - rx.foreach(AccueilState.sanctions, _sanction_card), - width="100%", - spacing="1", + rx.vstack( + rx.box( + rx.text("⚠ ", AccueilState.sanctions.length(), + " apprenti(s) en avis de sanction", + color="#B71C1C", size="2", font_weight="600"), + background_color="#fff5f5", + border="1px solid #ffcccc", + border_radius="6px", + padding="0.75rem 1rem", + width="100%", + ), + rx.foreach(AccueilState.sanctions, _sanction_card), + width="100%", + spacing="1", + ), ), + width="100%", ), rx.divider(), @@ -147,10 +153,12 @@ def accueil_page() -> rx.Component: border="1px solid #90caf9", border_radius="6px", padding="0.75rem 1rem", + width="100%", ), spacing="5", width="100%", + max_width="100%", align="start", padding_bottom="2rem", ) diff --git a/eptm_dashboard/pages/login.py b/eptm_dashboard/pages/login.py index 66ac3ca..104c9b5 100644 --- a/eptm_dashboard/pages/login.py +++ b/eptm_dashboard/pages/login.py @@ -4,46 +4,56 @@ from ..state import AuthState def login_page() -> rx.Component: return rx.center( - rx.vstack( - rx.image(src="/logo.png", width="160px"), - rx.heading("EPTM Dashboard", size="5", color="#37474f"), - rx.cond( - AuthState.login_error != "", - rx.box( - rx.text(AuthState.login_error, color="red", size="2"), - padding="0.5rem 1rem", - background_color="#fff5f5", - border="1px solid #ffcccc", - border_radius="6px", + rx.form( + rx.vstack( + rx.center( + rx.image(src="/logo.png", width="320px", height="auto"), width="100%", ), - ), - rx.input( - placeholder="Identifiant", - value=AuthState.login_user, - on_change=AuthState.set_login_user, + rx.cond( + AuthState.login_error != "", + rx.box( + rx.text(AuthState.login_error, color="red", size="2"), + padding="0.5rem 1rem", + background_color="#fff5f5", + border="1px solid #ffcccc", + border_radius="6px", + width="100%", + ), + ), + rx.input( + name="username", + placeholder="Identifiant", + value=AuthState.login_user, + on_change=AuthState.set_login_user, + width="100%", + ), + rx.input( + name="password", + placeholder="Mot de passe", + type="password", + value=AuthState.login_pass, + on_change=AuthState.set_login_pass, + width="100%", + ), + rx.button( + "Se connecter", + type="submit", + width="100%", + color_scheme="indigo", + ), + spacing="3", width="100%", + align="center", ), - rx.input( - placeholder="Mot de passe", - type="password", - value=AuthState.login_pass, - on_change=AuthState.set_login_pass, - width="100%", - ), - rx.button( - "Se connecter", - on_click=AuthState.handle_login, - width="100%", - color_scheme="indigo", - ), - spacing="3", - width="350px", + on_submit=AuthState.handle_login, + width="420px", padding="2rem", background_color="white", border_radius="8px", box_shadow="0 2px 16px rgba(0,0,0,0.08)", ), + width="100%", height="100vh", background_color="#f8f9fa", ) diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py index 4308413..0cbb5e7 100644 --- a/eptm_dashboard/sidebar.py +++ b/eptm_dashboard/sidebar.py @@ -1,9 +1,23 @@ import reflex as rx from .state import AuthState +FULL_W = "240px" +RAIL_W = "68px" +TOPBAR_H = "56px" + +# EPTM brand palette (logo: noir #000 + rouge #e00010) +_BG = "#f8f9fa" # sidebar background (light) +_BORDER = "#e5e7eb" # subtle separator +_TEXT = "#4b5563" # inactive text +_TEXT_MUTED = "#9ca3af" # muted labels +_ACTIVE_BG = "rgba(220, 0, 14, 0.18)" # EPTM red tint +_ACTIVE_CLR = "#ff4a54" # bright red on dark bg +_HOVER_BG = "#f3f4f6" +_USER_BG = "#f3f4f6" # slightly darker user section + _PAGES = [ - ("Tableau de bord", "/accueil", "house"), - ("À traiter", "/traiter", "triangle-alert"), + ("Tableau de bord", "/accueil", "layout-dashboard"), + ("A traiter", "/traiter", "triangle-alert"), ("Fiche apprenti", "/fiche", "user"), ("Vue classe", "/classe", "users"), ("Import", "/import", "upload"), @@ -14,109 +28,348 @@ _PAGES = [ _ADMIN_PAGES = [ ("Logs", "/logs", "file-text"), ("Utilisateurs", "/users", "user-cog"), - ("Paramètres", "/params", "settings"), + ("Parametres", "/params", "settings"), ] -def _nav_item(label: str, href: str, icon: str) -> rx.Component: +def _nav_full(label: str, href: str, icon_name: str, close_menu: bool = False) -> rx.Component: + is_active = AuthState.router.page.path == href + click_handler = AuthState.close_mobile_menu if close_menu else None return rx.link( rx.hstack( - rx.icon(icon, size=16, color="#9e9e9e"), - rx.text(label, size="2", color="#555555"), + rx.box( + width="3px", + height="100%", + min_height="20px", + border_radius="0 2px 2px 0", + background_color=rx.cond(is_active, _ACTIVE_CLR, "transparent"), + position="absolute", + left="0", + top="0", + ), + rx.icon( + icon_name, size=17, + color=rx.cond(is_active, _ACTIVE_CLR, _TEXT), + flex_shrink="0", + ), + rx.text( + label, size="2", + color=rx.cond(is_active, "#ffffff", _TEXT), + font_weight=rx.cond(is_active, "600", "400"), + white_space="nowrap", + overflow="hidden", + ), spacing="3", align="center", - padding_x="1rem", - padding_y="0.55rem", width="100%", + padding_x="0.75rem", + padding_y="0.5rem", + border_radius="0 6px 6px 0", + background_color=rx.cond(is_active, _ACTIVE_BG, "transparent"), + _hover={"background_color": _HOVER_BG}, + position="relative", ), href=href, - width="100%", + on_click=click_handler, text_decoration="none", - _hover={"background_color": "#f8f9fa"}, + width="100%", display="block", ) +def _nav_rail(label: str, href: str, icon_name: str) -> rx.Component: + is_active = AuthState.router.page.path == href + return rx.tooltip( + rx.link( + rx.box( + rx.icon(icon_name, size=20, + color=rx.cond(is_active, _ACTIVE_CLR, _TEXT)), + width="100%", + display="flex", + align_items="center", + justify_content="center", + padding_y="0.6rem", + border_radius="6px", + background_color=rx.cond(is_active, _ACTIVE_BG, "transparent"), + border_left=rx.cond(is_active, f"3px solid {_ACTIVE_CLR}", "3px solid transparent"), + _hover={"background_color": _HOVER_BG}, + ), + href=href, + text_decoration="none", + width="100%", + display="block", + ), + content=label, + side="right", + ) + + +def _nav_item(label: str, href: str, icon_name: str) -> rx.Component: + return rx.cond( + AuthState.sidebar_collapsed, + _nav_rail(label, href, icon_name), + _nav_full(label, href, icon_name), + ) + + +def _admin_section(mobile: bool = False) -> rx.Component: + return rx.cond( + AuthState.role == "admin", + rx.vstack( + rx.box(height="1px", width="100%", background_color=_BORDER), + rx.cond( + AuthState.sidebar_collapsed if not mobile else rx.Var.create(False), + # Rail mode + rx.vstack( + *[_nav_rail(l, h, i) for l, h, i in _ADMIN_PAGES], + spacing="1", width="100%", + padding_x="0.5rem", padding_y="0.5rem", + ), + # Full mode with collapsible + rx.vstack( + rx.button( + rx.hstack( + rx.icon("shield", size=13, color=_TEXT_MUTED), + rx.text("Admin", size="1", color=_TEXT_MUTED, + font_weight="600", letter_spacing="0.1em"), + rx.spacer(), + rx.icon( + rx.cond(AuthState.admin_expanded, "chevron-up", "chevron-down"), + size=13, color=_TEXT_MUTED, + ), + spacing="2", align="center", width="100%", + ), + on_click=AuthState.toggle_admin, + variant="ghost", + width="100%", + size="1", + padding_x="0.75rem", + padding_y="0.4rem", + color=_TEXT_MUTED, + _hover={"background_color": _HOVER_BG}, + cursor="pointer", + ), + rx.cond( + AuthState.admin_expanded, + rx.vstack( + *[ + _nav_full(l, h, i, close_menu=mobile) + for l, h, i in _ADMIN_PAGES + ], + spacing="1", width="100%", + ), + ), + spacing="0", width="100%", + padding_x="0.75rem", padding_y="0.25rem", + ), + ), + spacing="0", width="100%", + ), + ) + + +def _user_widget(collapsed: bool = False) -> rx.Component: + if collapsed: + return rx.tooltip( + rx.vstack( + rx.avatar(fallback=AuthState.name_initials, size="2", + color_scheme="ruby", radius="full"), + rx.icon_button( + rx.icon("log-out", size=14), + on_click=AuthState.logout, + variant="ghost", size="1", cursor="pointer", + ), + spacing="2", align="center", width="100%", + ), + content=AuthState.name, + side="right", + ) + return rx.hstack( + rx.avatar(fallback=AuthState.name_initials, size="2", + color_scheme="ruby", radius="full"), + rx.vstack( + rx.text(AuthState.name, size="2", font_weight="600", + color="#f3f4f6", white_space="nowrap", overflow="hidden"), + rx.text(AuthState.role, size="1", color=_TEXT_MUTED), + spacing="0", align="start", overflow="hidden", flex="1", + ), + rx.icon_button( + rx.icon("log-out", size=14), + on_click=AuthState.logout, + variant="ghost", size="1", cursor="pointer", + ), + spacing="2", align="center", width="100%", overflow="hidden", + ) + + +# ── Desktop sidebar ────────────────────────────────────────────────────────── + def sidebar() -> rx.Component: return rx.box( rx.vstack( - rx.box( - rx.image(src="/logo.png", width="140px"), - text_align="center", - padding="1rem", - border_bottom="1px solid #dee2e6", - width="100%", + # Header: logo + toggle + rx.hstack( + rx.cond( + AuthState.sidebar_collapsed, + rx.box(flex="1"), + rx.box( + rx.image(src="/logo.png", height="112px", + object_fit="contain", max_width="160px", width="100%"), + flex="1", + display="flex", + align_items="center", + justify_content="center", + min_width="0", + ), + ), + rx.icon_button( + rx.cond( + AuthState.sidebar_collapsed, + rx.icon("panel-left-open", size=16), + rx.icon("panel-left-close", size=16), + ), + on_click=AuthState.toggle_sidebar, + variant="ghost", size="2", + color=_TEXT, cursor="pointer", flex_shrink="0", + ), + width="100%", align="center", + padding_y="0.75rem", + padding_x=rx.cond(AuthState.sidebar_collapsed, "0.5rem", "0.75rem"), ), + + rx.box(height="1px", width="100%", background_color=_BORDER), + + # Nav rx.vstack( *[_nav_item(l, h, i) for l, h, i in _PAGES], - spacing="0", - width="100%", + spacing="1", width="100%", + padding_x=rx.cond(AuthState.sidebar_collapsed, "0.5rem", "0"), padding_y="0.5rem", ), - rx.cond( - AuthState.role == "admin", - rx.vstack( - rx.box( - rx.text( - "ADMIN", size="1", color="#9e9e9e", - font_weight="700", letter_spacing="0.12em", - ), - padding_x="1rem", - padding_top="0.75rem", - padding_bottom="0.25rem", - ), - *[_nav_item(l, h, i) for l, h, i in _ADMIN_PAGES], - spacing="0", - width="100%", + + _admin_section(), + rx.spacer(), + + # User + rx.box(height="1px", width="100%", background_color=_BORDER), + rx.box( + rx.cond( + AuthState.sidebar_collapsed, + _user_widget(collapsed=True), + _user_widget(collapsed=False), ), + padding_y="0.75rem", + padding_x=rx.cond(AuthState.sidebar_collapsed, "0.5rem", "0.75rem"), + width="100%", + background_color=_USER_BG, + ), + + height="100vh", width="100%", + spacing="0", align="start", + overflow_y="auto", overflow_x="hidden", + ), + class_name="sidebar-desktop", + background_color=_BG, + border_right=f"1px solid {_BORDER}", + position="fixed", + left="0", top="0", + height="100vh", + width=rx.cond(AuthState.sidebar_collapsed, RAIL_W, FULL_W), + transition="width 0.22s ease", + z_index="100", + overflow="hidden", + ) + + +# ── Mobile top bar ─────────────────────────────────────────────────────────── + +def _mobile_topbar() -> rx.Component: + return rx.box( + # Bar row + rx.hstack( + rx.box( + rx.image(src="/logo.png", height="40px", object_fit="contain"), + background_color="white", + border_radius="5px", + padding="4px 8px", + display="flex", + align_items="center", + justify_content="center", ), rx.spacer(), + rx.icon_button( + rx.cond( + AuthState.mobile_menu_open, + rx.icon("x", size=20), + rx.icon("menu", size=20), + ), + on_click=AuthState.toggle_mobile_menu, + variant="ghost", size="2", + color=_TEXT, cursor="pointer", + ), + width="100%", align="center", + padding_x="1rem", + height=TOPBAR_H, + ), + + # Dropdown + rx.cond( + AuthState.mobile_menu_open, rx.box( rx.vstack( - rx.text(AuthState.name, size="2", font_weight="600", color="#37474f"), - rx.text(AuthState.role, size="1", color="#9e9e9e"), - rx.button( - "↩ Déconnexion", - on_click=AuthState.logout, - size="1", - variant="outline", - color_scheme="gray", - width="100%", + rx.box(height="1px", width="100%", background_color=_BORDER), + rx.vstack( + *[_nav_full(l, h, i, close_menu=True) for l, h, i in _PAGES], + spacing="1", width="100%", + padding_x="0", padding_y="0.5rem", ), - spacing="2", - width="100%", + _admin_section(mobile=True), + rx.box(height="1px", width="100%", background_color=_BORDER), + rx.box( + _user_widget(collapsed=False), + padding_x="0.75rem", padding_y="0.65rem", + background_color=_USER_BG, width="100%", + ), + spacing="0", width="100%", ), - padding="1rem", - border_top="1px solid #dee2e6", + background_color=_BG, width="100%", + max_height=f"calc(100vh - {TOPBAR_H})", + overflow_y="auto", ), - height="100vh", - width="240px", - spacing="0", - align="start", ), - background_color="white", - border_right="1px solid #dee2e6", + + class_name="topbar-mobile", + background_color=_BG, + border_bottom=f"1px solid {_BORDER}", position="fixed", - left="0", - top="0", - height="100vh", - z_index="100", + top="0", left="0", right="0", + width="100%", + z_index="200", ) +# ── Layout wrapper ─────────────────────────────────────────────────────────── + def layout(content: rx.Component) -> rx.Component: - return rx.hstack( + return rx.box( sidebar(), + _mobile_topbar(), rx.box( content, - margin_left="240px", - padding="2rem", - width="100%", - min_height="100vh", - background_color="#f8f9fa", + class_name="content-area", + padding=rx.cond(AuthState.sidebar_collapsed, "1rem", "1.5rem"), + background_color="var(--gray-2)", + margin_left=rx.cond(AuthState.sidebar_collapsed, RAIL_W, FULL_W), + width=rx.cond( + AuthState.sidebar_collapsed, + f"calc(100% - {RAIL_W})", + f"calc(100% - {FULL_W})", + ), + transition="margin-left 0.22s ease, width 0.22s ease", + box_sizing="border-box", ), - spacing="0", - align="start", width="100%", + height="100vh", + overflow="hidden", ) diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py index eefbb6e..f2351dd 100644 --- a/eptm_dashboard/state.py +++ b/eptm_dashboard/state.py @@ -17,6 +17,31 @@ class AuthState(rx.State): login_pass: str = "" login_error: str = "" + sidebar_collapsed: bool = False + mobile_menu_open: bool = False + admin_expanded: bool = True + + @rx.var + def name_initials(self) -> str: + if not self.name: + return "?" + parts = self.name.split() + if len(parts) >= 2: + return (parts[0][0] + parts[1][0]).upper() + return self.name[:2].upper() + + def toggle_sidebar(self): + self.sidebar_collapsed = not self.sidebar_collapsed + + def toggle_mobile_menu(self): + self.mobile_menu_open = not self.mobile_menu_open + + def close_mobile_menu(self): + self.mobile_menu_open = False + + def toggle_admin(self): + self.admin_expanded = not self.admin_expanded + def set_login_user(self, value: str): self.login_user = value @@ -27,7 +52,7 @@ class AuthState(rx.State): if not self.authenticated: return rx.redirect("/login") - def handle_login(self): + def handle_login(self, form_data: dict | None = None): self.login_error = "" users = self._load_users() user = users.get(self.login_user) diff --git a/rxconfig.py b/rxconfig.py index 9c8940f..6ac7538 100644 --- a/rxconfig.py +++ b/rxconfig.py @@ -8,5 +8,15 @@ config = rx.Config( frontend_port=int(os.getenv("FRONTEND_PORT", "3000")), backend_port=int(os.getenv("BACKEND_PORT", "8000")), vite_allowed_hosts=True, + plugins=[ + rx.plugins.RadixThemesPlugin( + theme=rx.theme( + appearance="inherit", + accent_color="red", + radius="medium", + scaling="95%", + ) + ), + ], disable_plugins=[SitemapPlugin], )