main layout ok

This commit is contained in:
Julien Balet 2026-05-08 00:34:51 +02:00
parent d812eabdbd
commit 360e8e02a7
10 changed files with 565 additions and 128 deletions

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

58
assets/favicon.svg Normal file
View file

@ -0,0 +1,58 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<clipPath id="rnd">
<rect width="512" height="512" rx="108"/>
</clipPath>
</defs>
<!-- Fond noir -->
<rect width="512" height="512" rx="108" fill="#1A1A1A"/>
<!-- Bandeau rouge droite -->
<rect x="346" y="0" width="166" height="512" fill="#CC1111" clip-path="url(#rnd)"/>
<!-- Toit diagonal style EPTM -->
<polygon points="300,0 346,0 346,96 300,60" fill="#1A1A1A" clip-path="url(#rnd)"/>
<!-- Calendrier — corps -->
<rect x="64" y="112" width="262" height="214" rx="20" fill="white" fill-opacity="0.07" stroke="white" stroke-opacity="0.25" stroke-width="2.5"/>
<!-- Calendrier — en-tête -->
<rect x="64" y="112" width="262" height="62" rx="20" fill="white" fill-opacity="0.12"/>
<rect x="64" y="150" width="262" height="24" fill="white" fill-opacity="0.07"/>
<!-- Anneaux -->
<rect x="138" y="82" width="18" height="52" rx="9" fill="white" fill-opacity="0.7"/>
<rect x="238" y="82" width="18" height="52" rx="9" fill="white" fill-opacity="0.7"/>
<!-- Titre dans l'en-tête -->
<rect x="104" y="128" width="76" height="9" rx="4" fill="white" fill-opacity="0.45"/>
<!-- Grille ligne 1 -->
<circle cx="136" cy="234" r="18" fill="#22C55E"/>
<line x1="126" y1="234" x2="133" y2="242" stroke="white" stroke-width="4" stroke-linecap="round"/>
<line x1="133" y1="242" x2="148" y2="225" stroke="white" stroke-width="4" stroke-linecap="round"/>
<circle cx="196" cy="234" r="18" fill="#22C55E"/>
<line x1="186" y1="234" x2="193" y2="242" stroke="white" stroke-width="4" stroke-linecap="round"/>
<line x1="193" y1="242" x2="208" y2="225" stroke="white" stroke-width="4" stroke-linecap="round"/>
<circle cx="256" cy="234" r="18" fill="#CC1111"/>
<line x1="246" y1="224" x2="266" y2="244" stroke="white" stroke-width="4" stroke-linecap="round"/>
<line x1="266" y1="224" x2="246" y2="244" stroke="white" stroke-width="4" stroke-linecap="round"/>
<!-- Grille ligne 2 -->
<circle cx="136" cy="294" r="18" fill="#F59E0B"/>
<text x="136" y="301" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="800" font-size="20" fill="white">E</text>
<circle cx="196" cy="294" r="18" fill="#22C55E"/>
<line x1="186" y1="294" x2="193" y2="302" stroke="white" stroke-width="4" stroke-linecap="round"/>
<line x1="193" y1="302" x2="208" y2="285" stroke="white" stroke-width="4" stroke-linecap="round"/>
<circle cx="256" cy="294" r="18" fill="#CC1111"/>
<line x1="246" y1="284" x2="266" y2="304" stroke="white" stroke-width="4" stroke-linecap="round"/>
<line x1="266" y1="284" x2="246" y2="304" stroke="white" stroke-width="4" stroke-linecap="round"/>
<!-- Texte EPTM — un seul bloc pour espacement uniforme, M tombe sur le rouge -->
<text x="118" y="432" text-anchor="start" font-family="Arial,system-ui,sans-serif" font-weight="900" font-size="108" letter-spacing="-2" fill="white">EPTM</text>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

64
assets/responsive.css Normal file
View file

@ -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%;
}

View file

@ -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:

View file

@ -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)

View file

@ -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,11 +106,13 @@ 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.box(
rx.cond(
AccueilState.sanctions.length() == 0,
rx.box(
@ -119,6 +122,7 @@ def accueil_page() -> rx.Component:
border="1px solid #c8e6c9",
border_radius="6px",
padding="0.75rem 1rem",
width="100%",
),
rx.vstack(
rx.box(
@ -136,6 +140,8 @@ def accueil_page() -> rx.Component:
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",
)

View file

@ -4,9 +4,12 @@ from ..state import AuthState
def login_page() -> rx.Component:
return rx.center(
rx.form(
rx.vstack(
rx.image(src="/logo.png", width="160px"),
rx.heading("EPTM Dashboard", size="5", color="#37474f"),
rx.center(
rx.image(src="/logo.png", width="320px", height="auto"),
width="100%",
),
rx.cond(
AuthState.login_error != "",
rx.box(
@ -19,12 +22,14 @@ def login_page() -> rx.Component:
),
),
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,
@ -33,17 +38,22 @@ def login_page() -> rx.Component:
),
rx.button(
"Se connecter",
on_click=AuthState.handle_login,
type="submit",
width="100%",
color_scheme="indigo",
),
spacing="3",
width="350px",
width="100%",
align="center",
),
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",
)

View file

@ -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(
# Header: logo + toggle
rx.hstack(
rx.cond(
AuthState.sidebar_collapsed,
rx.box(flex="1"),
rx.box(
rx.image(src="/logo.png", width="140px"),
text_align="center",
padding="1rem",
border_bottom="1px solid #dee2e6",
width="100%",
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(
_admin_section(),
rx.spacer(),
# User
rx.box(height="1px", width="100%", background_color=_BORDER),
rx.box(
rx.text(
"ADMIN", size="1", color="#9e9e9e",
font_weight="700", letter_spacing="0.12em",
rx.cond(
AuthState.sidebar_collapsed,
_user_widget(collapsed=True),
_user_widget(collapsed=False),
),
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",
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",
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",
),
_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%",
),
background_color=_BG,
width="100%",
max_height=f"calc(100vh - {TOPBAR_H})",
overflow_y="auto",
),
spacing="2",
width="100%",
),
padding="1rem",
border_top="1px solid #dee2e6",
width="100%",
),
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",
)

View file

@ -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)

View file

@ -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],
)