Vortex-Flux / src /modules /ml_dashboard.py
jaimalklyde's picture
Claude Sonnet 5.5
JASMINE chat: control guides and what-if data on every chat page
2308a61
Raw History Blame Contribute Delete
30.8 kB
import streamlit as st
import pandas as pd
import plotly.graph_objects as go
from datetime import datetime
from modules.jasmine_widget import render_scoped_widget
from Analytics.vortex_params import get_objectif_annuel
from core.sheets_cache import get_cached_dfs_batch, get_cached_df
from core import exercice as ex
def show_ml_features(client, sheet_name):
# Fonction helper pour charger les données (mise en cache, 1 appel par feuille)
def get_data_from_sheet(sheet_name_tab):
try:
sh = client.open(sheet_name)
return get_cached_df(sh, sheet_name, sheet_name_tab)
except Exception as e:
from core.errors import log_swallowed
log_swallowed(f"Chargement de l'onglet '{sheet_name_tab}' echoue (tableau de bord traite comme vide)", e)
st.warning(f"[ATTENTION] Impossible de charger l'onglet « {sheet_name_tab} » — les indicateurs ci-dessous peuvent être incomplets.")
return pd.DataFrame()
# Chargement des 4 feuilles necessaires EN UN SEUL appel API groupe (au lieu
# de 4 separes) ; si l'une d'elles manque, le groupe entier echoue et on
# retombe sur le chargement feuille par feuille (resilient, comme avant).
_noms = ["Prets_Master", "Prets_Update", "Capital_Investissement", "Clients_KYC", "Remboursements"]
try:
sh = client.open(sheet_name)
_dfs = get_cached_dfs_batch(sh, sheet_name, _noms)
df_prets_master, df_prets_update, df_capital_invest, df_clients, df_remb = (
_dfs[n] for n in _noms
)
except Exception:
df_prets_master = get_data_from_sheet("Prets_Master")
df_prets_update = get_data_from_sheet("Prets_Update")
df_capital_invest = get_data_from_sheet("Capital_Investissement")
df_clients = get_data_from_sheet("Clients_KYC")
df_remb = get_data_from_sheet("Remboursements")
# Exercice choisi dans la barre laterale (None = toutes annees). Regle A :
# benefice = interets + penalites ENCAISSES dans l'annee (Date_Paiement),
# capital sorti = prets debloques dans l'annee (Date_Deblocage).
annee_sel = ex.exercice_selectionne()
annee_courante = annee_sel or ex.exercice_courant()
libelle_annee = str(annee_sel) if annee_sel else "TOUTES ANNÉES"
# === En-tête ===
st.markdown("<h1>OBJECT EXPLORER</h1>", unsafe_allow_html=True)
# Base du benefice choisie dans la barre laterale (partagee avec Optimizer,
# Digital Twin, rapports IA et JASMINE) : encaisse ou accorde.
base_benefice = ex.base_benefice()
mode_accorde = ex.est_base_accordee(base_benefice)
st.caption(ex.definition_base(base_benefice))
# === VÉRIFICATION RÉELLE DE LA LIAISON ===
# On s'assure que la table principale a bien été trouvée et contient des données
if not df_prets_master.empty:
# Message de succès "Tactique" avec auto-destruction CSS (Fade out après 5s)
st.markdown(f"""
<style>
@keyframes tacticalFadeOut {{
0% {{ opacity: 1; max-height: 50px; margin-bottom: 16px; padding: 12px 16px; border-width: 1px; }}
80% {{ opacity: 1; max-height: 50px; margin-bottom: 16px; padding: 12px 16px; border-width: 1px; }}
100% {{ opacity: 0; max-height: 0px; margin-bottom: 0px; padding: 0px; border-width: 0px; overflow: hidden; }}
}}
.tactical-toast {{
background: rgba(84, 189, 75, 0.08);
border: 1px solid rgba(84, 189, 75, 0.5);
border-left: 4px solid #54bd4b;
color: #54bd4b;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
animation: tacticalFadeOut 10s forwards;
}}
</style>
<div class="tactical-toast">
[OK] LIAISON DIRECTE ÉTABLIE : {sheet_name}
</div>
""", unsafe_allow_html=True)
else:
# Message d'erreur fixe si la feuille est introuvable ou vide
st.markdown(f"""
<div style="background: rgba(192, 57, 43, 0.08); border: 1px solid rgba(192, 57, 43, 0.3); border-left: 4px solid #c0392b; padding: 12px 16px; margin-bottom: 16px; font-family: 'JetBrains Mono', monospace;">
<div style="color: #c0392b; font-weight: 800; font-size: 0.75rem; letter-spacing: 1px; text-transform: uppercase;">
[X] ÉCHEC DE LIAISON : AUCUNE DONNÉE DANS 'Prets_Master'
</div>
</div>
""", unsafe_allow_html=True)
# === CALCULS POUR LES MÉTRIQUES ===
# 1. Capital de l'entreprise (somme de Capital_Investissement)
capital_entreprise = 0
capital_variation = 0
if not df_capital_invest.empty and 'Capital' in df_capital_invest.columns:
df_capital_invest['Capital_Num'] = pd.to_numeric(
df_capital_invest['Capital'].astype(str).str.replace('FCFA', '').str.replace(' ', '').str.strip(),
errors='coerce'
).fillna(0)
capital_entreprise = df_capital_invest['Capital_Num'].sum()
# Variation = dernier vs précédent
if len(df_capital_invest) > 1:
derniere_entree = df_capital_invest.iloc[-1]['Capital_Num']
avant_derniere = df_capital_invest.iloc[-2]['Capital_Num']
if avant_derniere > 0:
capital_variation = ((derniere_entree - avant_derniere) / avant_derniere) * 100
# 2. Capital dehors (prêts non remboursés)
capital_dehors = 0
if not df_prets_master.empty and 'Montant_Capital' in df_prets_master.columns:
df_prets_master['Montant_Capital_Num'] = pd.to_numeric(df_prets_master['Montant_Capital'], errors='coerce').fillna(0)
capital_dehors = df_prets_master[df_prets_master['Statut'] != 'TERMINE']['Montant_Capital_Num'].sum()
# 3. Flux attendu (Montant_Total des prêts non remboursés)
flux_attendu = 0
flux_variation = 0
if not df_prets_master.empty and 'Montant_Total' in df_prets_master.columns:
df_prets_master['Montant_Total_Num'] = pd.to_numeric(df_prets_master['Montant_Total'], errors='coerce').fillna(0)
flux_attendu = df_prets_master[df_prets_master['Statut'] != 'TERMINE']['Montant_Total_Num'].sum()
# Variation = différence avec capital dehors
if capital_dehors > 0:
flux_variation = ((flux_attendu - capital_dehors) / capital_dehors) * 100
# 4. Score de liquidité
score_liquidite = 0
score_tendance = "Stable"
if capital_entreprise > 0:
score_liquidite = (capital_dehors / capital_entreprise) * 10
score_liquidite = min(score_liquidite, 10)
# Tendance basée sur le ratio
ratio = (capital_dehors / capital_entreprise) * 100
if ratio > 70:
score_tendance = "▲ Élevé"
elif ratio > 40:
score_tendance = "→ Moyen"
else:
score_tendance = "▼ Faible"
# 5. Benefice de l'exercice (selon la base choisie) et comparaison a N-1 a la meme date
encaisse_par_annee = ex.encaissements_par_exercice(df_remb)
benefices_nets = ex.benefice_exercice(df_prets_master, df_remb, annee_sel, base_benefice)
benefices_variation = 0
benefice_n1_meme_date = None
if annee_sel:
from datetime import date as _date
aujourdhui = _date.today()
limite = aujourdhui.replace(year=annee_sel - 1) if annee_sel == aujourdhui.year and not (aujourdhui.month == 2 and aujourdhui.day == 29) else None
benefice_n1_meme_date = ex.benefice_exercice(df_prets_master, df_remb, annee_sel - 1, base_benefice, jusqu_au=limite)
if benefice_n1_meme_date > 0:
benefices_variation = (benefices_nets - benefice_n1_meme_date) / benefice_n1_meme_date * 100
synthese_n = ex.synthese_exercice(df_prets_master, df_remb, annee_courante)
synthese_n1 = ex.synthese_exercice(df_prets_master, df_remb, annee_courante - 1)
# 6. Objectif de l'exercice (source de verite unique, voir Optimizer)
objectif_annuel = get_objectif_annuel(client, sheet_name, annee=annee_courante)
progression_objectif = (benefices_nets / objectif_annuel) * 100 if objectif_annuel > 0 else 0
# 7. Reste à générer
reste_a_generer = max(0, objectif_annuel - benefices_nets)
reste_variation = -((reste_a_generer / objectif_annuel) * 100) if objectif_annuel > 0 else 0
# 8. Capital total sorti (prets debloques dans l'exercice)
def _capital_sorti(master, update):
"""(capital, nb prets) : Master hors UPDATED + versions Prets_Update
des prets UPDATED + prets presents uniquement dans Prets_Update."""
if master.empty or 'Montant_Capital' not in master.columns:
return 0, 0
master = master.assign(Montant_Capital_Num=pd.to_numeric(master['Montant_Capital'], errors='coerce').fillna(0))
non_updated = master[master['Statut'] != 'UPDATED']
capital, nb = non_updated['Montant_Capital_Num'].sum(), len(non_updated)
if not update.empty and 'ID_Pret' in update.columns:
update = update.assign(Montant_Capital_Num=pd.to_numeric(update['Montant_Capital'], errors='coerce').fillna(0))
ids_updated = master.loc[master['Statut'] == 'UPDATED', 'ID_Pret'].tolist()
versions = update[update['ID_Pret'].isin(ids_updated)]
nouveaux = update[~update['ID_Pret'].isin(master['ID_Pret'].tolist())]
capital += versions['Montant_Capital_Num'].sum() + nouveaux['Montant_Capital_Num'].sum()
nb += len(versions) + len(nouveaux)
return capital, nb
master_ex = ex.filtrer_annee(df_prets_master, 'Date_Deblocage', annee_sel)
if annee_sel and 'ID_Pret' in master_ex.columns and not df_prets_update.empty and 'ID_Pret' in df_prets_update.columns:
# Versions des prets du millesime + prets debloques dans l'exercice presents seulement dans Prets_Update.
ids_ex = set(master_ex['ID_Pret'].astype(str))
dans_millesime = df_prets_update['ID_Pret'].astype(str).isin(ids_ex)
if 'Date_Deblocage' in df_prets_update.columns:
dans_millesime |= ex.annee_de(df_prets_update['Date_Deblocage']).eq(annee_sel).fillna(False).to_numpy()
update_ex = df_prets_update[dans_millesime]
else:
update_ex = df_prets_update
capital_total_sorti, nb_prets_total = _capital_sorti(master_ex, update_ex)
# === AFFICHAGE DES MÉTRIQUES (2 lignes de 4) ===
# Ligne 1
col1, col2, col3, col4 = st.columns(4)
col1.metric(
"CAPITAL D'INVESTISSEMENT",
f"{capital_entreprise:,.0f} XOF".replace(',', ' '),
delta=f"{'▲' if capital_variation > 0 else '▼'} {abs(capital_variation):.1f}%" if capital_variation != 0 else "Stable"
)
col2.metric(
"CAPITAL DEHORS",
f"{capital_dehors:,.0f} XOF".replace(',', ' '),
delta=f"{(capital_dehors/capital_entreprise*100 if capital_entreprise > 0 else 0):.1f}% du capital investi"
)
col3.metric(
"FLUX ATTENDU",
f"{flux_attendu:,.0f} XOF".replace(',', ' '),
delta=f"{'▲' if flux_variation > 0 else '▼'} {abs(flux_variation):.1f}% vs capital" if flux_variation != 0 else "Stable"
)
col4.metric(
"LIQUIDITÉ UTILISÉE",
f"{score_liquidite:.1f}/10",
delta=score_tendance
)
# Ligne 2
col5, col6, col7, col8 = st.columns(4)
col5.metric(
f"BÉNÉFICES {'ACCORDÉS' if mode_accorde else 'ENCAISSÉS'} {libelle_annee}",
f"{benefices_nets:,.0f} XOF".replace(',', ' '),
delta=(f"{benefices_variation:+.1f}% vs {annee_courante - 1} à date" if benefice_n1_meme_date else "Pas de N-1 comparable"),
help=("Coût du crédit des prêts débloqués dans l'exercice (date de déblocage)." if mode_accorde
else "Intérêts + pénalités encaissés dans l'exercice (date de paiement)."),
)
col6.metric(
f"OBJECTIF {annee_courante}",
f"{objectif_annuel:,.0f} XOF".replace(',', ' '),
delta=f"{progression_objectif:.1f}% atteint"
)
col7.metric(
"RESTE À GÉNÉRER",
f"{reste_a_generer:,.0f} XOF".replace(',', ' '),
delta=f"[BAS] {abs(reste_variation):.1f}%" if reste_a_generer > 0 else "[OK] Objectif atteint"
)
col8.metric(
f"CAPITAL SORTI {libelle_annee}",
f"{capital_total_sorti:,.0f} XOF".replace(',', ' '),
delta=f"{nb_prets_total} prêt{'s' if nb_prets_total > 1 else ''} tot{'aux' if nb_prets_total > 1 else 'al'}"
)
# Répartition mensuelle (bénéfices, volume, capital sorti) — sans ça JASMINE
# ne peut répondre à aucune question temporelle ("meilleur mois", tendance...).
repartition_mensuelle = []
if not df_prets_master.empty and 'Date_Deblocage' in df_prets_master.columns:
df_mois = ex.filtrer_annee(df_prets_master, 'Date_Deblocage', annee_sel)
df_mois['_date_debloc'] = ex.parser_dates(df_mois['Date_Deblocage']) if not df_mois.empty else pd.Series(dtype='datetime64[ns]')
df_mois = df_mois.dropna(subset=['_date_debloc'])
if not df_mois.empty:
df_mois['_mois'] = df_mois['_date_debloc'].dt.strftime('%Y-%m')
df_mois['_cout_credit_num'] = pd.to_numeric(df_mois.get('Cout_Credit', 0), errors='coerce').fillna(0)
df_mois['_montant_capital_num'] = pd.to_numeric(df_mois.get('Montant_Capital', 0), errors='coerce').fillna(0)
agg = (
df_mois.groupby('_mois')
.agg(
nb_prets=('_cout_credit_num', 'size'),
benefices_xof=('_cout_credit_num', 'sum'),
capital_sorti_xof=('_montant_capital_num', 'sum'),
)
.reset_index()
.rename(columns={'_mois': 'mois'})
.sort_values('mois')
)
repartition_mensuelle = agg.to_dict('records')
render_scoped_widget(
client, sheet_name,
scope_label="Vue d'ensemble des indicateurs financiers (capital, liquidité, bénéfices)",
context_data={
"capital_entreprise_xof": capital_entreprise,
"capital_dehors_xof": capital_dehors,
"flux_attendu_xof": flux_attendu,
"score_liquidite": score_liquidite,
"exercice_analyse": annee_sel or "toutes annees",
"base_benefice_affichee": base_benefice,
"benefices_nets_xof": benefices_nets,
"benefice_n_moins_1_meme_date_xof": benefice_n1_meme_date,
"objectif_annuel_xof": objectif_annuel,
"synthese_exercice": synthese_n,
"synthese_exercice_precedent": synthese_n1,
"benefice_encaisse_par_exercice": encaisse_par_annee.to_dict("records"),
"reste_a_generer_xof": reste_a_generer,
"capital_total_sorti_xof": capital_total_sorti,
"nb_prets_total": nb_prets_total,
"repartition_mensuelle_nb_prets_benefices_capital_sorti": repartition_mensuelle,
"benefice_exercice_si_base_encaissee_xof": ex.benefice_exercice(df_prets_master, df_remb, annee_sel, ex.BASE_ENCAISSE),
"benefice_exercice_si_base_accordee_xof": ex.benefice_exercice(df_prets_master, df_remb, annee_sel, ex.BASE_ACCORDE),
"guide_controles": (
"Cette page n'a pas de réglage propre : l'exercice analysé et la BASE DU BÉNÉFICE (Encaissé ou Accordé) se "
"choisissent dans la barre latérale et recalculent tous les chiffres de la page. Passer d'une base à l'autre "
"change le bénéfice (voir benefice_exercice_si_base_encaissee_xof et benefice_exercice_si_base_accordee_xof) "
"mais pas le capital, la liquidité ni le flux attendu. Changer d'exercice change tout ce qui est daté. "
"Le bouton ACTUALISER LES DONNÉES de la barre latérale recharge les feuilles Google Sheets."
),
"guide_page": (
"Vue d'ensemble financière de l'entreprise (OBJECT EXPLORER) : capital disponible, "
"capital actuellement prêté, bénéfices générés et progression vers l'objectif annuel."
),
"definitions_champs": {
"capital_entreprise_xof": "Capital total investi dans l'entreprise (somme de la feuille Capital_Investissement).",
"capital_dehors_xof": "Somme des capitaux (Montant_Capital) des prêts NON terminés — argent actuellement prêté aux clients.",
"flux_attendu_xof": "Somme des montants totaux (capital+intérêts) des prêts non terminés — ce qui doit encore rentrer en caisse.",
"score_liquidite": "Ratio capital_dehors / capital_entreprise x 10 (plafonné à 10) — plus il est élevé, plus une grande part du capital de l'entreprise est immobilisée en prêts en cours.",
"exercice_analyse": "Exercice (annee civile) analyse. Chaque flux est rattache a l'annee de SA date : deblocage -> annee de deblocage, paiement -> annee de paiement. Ne jamais melanger les annees.",
"base_benefice_affichee": "Base choisie par l'utilisateur pour le benefice de la page : 'Encaissé' = interets + penalites recus, au mois du paiement (base officielle de l'objectif) ; 'Accordé' = cout du credit des prets, au mois du deblocage (activite commerciale).",
"benefices_nets_xof": "Benefice de l'exercice selon base_benefice_affichee.",
"benefice_n_moins_1_meme_date_xof": "Benefice encaisse l'annee precedente a la meme date (comparaison a periode egale).",
"objectif_annuel_xof": "Objectif de benefice de l'exercice fixe par le gerant (meme valeur que dans le module Optimizer).",
"synthese_exercice": "Chiffres cles de l'exercice : " + "; ".join(f"{k} = {v}" for k, v in ex.DEFINITIONS_SYNTHESE.items()),
"synthese_exercice_precedent": "Memes chiffres pour l'exercice precedent.",
"benefice_encaisse_par_exercice": "Benefice encaisse (interets + penalites) de chaque exercice de l'historique.",
"reste_a_generer_xof": "Objectif de l'exercice moins benefice deja encaisse (0 si deja atteint).",
"capital_total_sorti_xof": "Capital decaisse sur les prets debloques dans l'exercice (Master + versions mises a jour).",
"repartition_mensuelle_nb_prets_benefices_capital_sorti": "Detail mois par mois des prets debloques dans l'exercice : nombre de prets, cout du credit contractuel et capital sorti.",
},
},
key_prefix="dashboard",
)
#======> ANALYSE MENSUELLE DES BÉNÉFICES <=====
st.divider()
st.markdown(f"<h2>ANALYSE MENSUELLE DES BÉNÉFICES {annee_courante} — {'ACCORDÉS' if mode_accorde else 'ENCAISSÉS'}</h2>", unsafe_allow_html=True)
# Préparation des données mensuelles (base choisie : mois du paiement ou du deblocage)
df_remb_n, col_date_benef = ex.lignes_benefice(df_prets_master, df_remb, annee_courante, base_benefice)
if col_date_benef in df_remb_n.columns:
df_remb_n['Mois'] = ex.parser_dates(df_remb_n[col_date_benef]).dt.month
df_remb_n['Benefice'] = ex.valeurs_benefice(df_remb_n, base_benefice)
df_2026 = ex.filtrer_annee(df_prets_master, 'Date_Deblocage', annee_courante)
df_2026['Mois'] = ex.parser_dates(df_2026['Date_Deblocage']).dt.month if not df_2026.empty else pd.Series(dtype=float)
if not df_remb_n.empty:
# CRÉER d'abord benefices_mensuels
benefices_mensuels = df_remb_n.groupby('Mois')['Benefice'].sum().reset_index()
benefices_mensuels.columns = ['Mois', 'Benefice']
# Ajouter les noms de mois
mois_noms = {1: 'Janvier', 2: 'Février', 3: 'Mars', 4: 'Avril',
5: 'Mai', 6: 'Juin', 7: 'Juillet', 8: 'Août',
9: 'Septembre', 10: 'Octobre', 11: 'Novembre', 12: 'Décembre'}
benefices_mensuels['Mois_Nom'] = benefices_mensuels['Mois'].map(mois_noms)
# Calculer les variations mensuelles (% vs mois précédent)
benefices_mensuels['Variation'] = benefices_mensuels['Benefice'].pct_change() * 100
benefices_mensuels['Variation'] = benefices_mensuels['Variation'].fillna(0)
# Calculer le capital mensuel sorti (Montant_Capital)
df_2026['Montant_Capital_Num'] = pd.to_numeric(df_2026.get('Montant_Capital', 0), errors='coerce').fillna(0)
capital_mensuel = df_2026.groupby('Mois')['Montant_Capital_Num'].sum().reset_index()
capital_mensuel.columns = ['Mois', 'Capital_Sorti']
# Fusionner avec les bénéfices mensuels
benefices_mensuels = benefices_mensuels.merge(capital_mensuel, on='Mois', how='left')
benefices_mensuels['Capital_Sorti'] = benefices_mensuels['Capital_Sorti'].fillna(0)
# === GRAPHIQUE EN BARRES (Police Space Grotesk via CSS Global) ===
fig_mensuel = go.Figure()
# Barres des bénéfices
fig_mensuel.add_trace(go.Bar(
x=benefices_mensuels['Mois_Nom'],
y=benefices_mensuels['Benefice'],
name='Bénéfices',
marker=dict(
color='#58a6ff',
line=dict(color='rgba(88, 166, 255, 0.4)', width=1)
),
text=benefices_mensuels['Benefice'].apply(lambda x: f"{x:,.0f}".replace(',', ' ')),
textposition='outside',
textfont=dict(size=11, color='#c9d1d9', family='Space Grotesk'),
opacity=0.9,
hovertemplate='<b>%{x}</b><br>Bénéfice: %{y:,.0f} XOF<extra></extra>'
))
# Barres du capital sorti (Tactical Red)
fig_mensuel.add_trace(go.Bar(
x=benefices_mensuels['Mois_Nom'],
y=benefices_mensuels['Capital_Sorti'],
name='Capital Sorti',
marker=dict(
color='#c0392b',
line=dict(color='rgba(192, 57, 43, 0.4)', width=1)
),
text=benefices_mensuels['Capital_Sorti'].apply(lambda x: f"{x:,.0f}".replace(',', ' ')),
textposition='outside',
textfont=dict(size=11, color='#c9d1d9', family='Space Grotesk'),
opacity=0.9,
hovertemplate='<b>%{x}</b><br>Capital Sorti: %{y:,.0f} XOF<extra></extra>'
))
# Mise en forme Gotham (Fond sombre, grille discrète)
fig_mensuel.update_layout(
title={
'text': f'ÉVOLUTION DES BÉNÉFICES ET CAPITAL SORTI MENSUELS ({annee_courante})',
'font': {'size': 14, 'color': '#8b949e', 'family': 'Space Grotesk'},
'x': 0,
'xanchor': 'left'
},
plot_bgcolor='rgba(10, 14, 18, 0.9)',
paper_bgcolor='rgba(0, 0, 0, 0)',
font={'color': '#8b949e', 'family': 'Space Grotesk', 'size': 12},
xaxis={
'title': '',
'gridcolor': 'rgba(50, 65, 80, 0.25)',
'linecolor': 'rgba(80, 100, 120, 0.3)',
'tickfont': {'family': 'Space Grotesk', 'size': 12},
'tickangle': -45
},
yaxis={
'title': {
'text': 'Montant (XOF)',
'font': {'size': 12, 'family': 'Space Grotesk'}
},
'gridcolor': 'rgba(50, 65, 80, 0.25)',
'linecolor': 'rgba(80, 100, 120, 0.3)',
'tickfont': {'family': 'Space Grotesk', 'size': 11}
},
barmode='group',
showlegend=True,
legend=dict(
orientation='h',
yanchor='bottom',
y=1.02,
xanchor='right',
x=1,
font={'family': 'Space Grotesk', 'size': 11, 'color': '#a8b8c8'},
bgcolor='rgba(10, 14, 18, 0.7)',
bordercolor='rgba(80, 100, 120, 0.3)',
borderwidth=1
),
height=450,
margin=dict(t=80, b=80, l=60, r=20)
)
st.plotly_chart(fig_mensuel, use_container_width=True)
# === TREEMAP DES VARIATIONS MENSUELLES ===
st.markdown("<h3 style='font-size: 1rem; color: #58a6ff; margin-top: 32px; margin-bottom: 16px; font-family: JetBrains Mono; letter-spacing: 2px; text-transform: uppercase;'>Répartition mensuelle des bénéfices</h3>", unsafe_allow_html=True)
df_treemap = benefices_mensuels[benefices_mensuels['Benefice'] > 0].copy()
if not df_treemap.empty:
total_benefices = df_treemap['Benefice'].sum()
df_treemap['Pourcentage'] = (df_treemap['Benefice'] / total_benefices * 100).round(1)
df_treemap['Label'] = df_treemap.apply(
lambda row: f"{row['Mois_Nom']}<br>{row['Pourcentage']:.1f}%",
axis=1
)
fig_treemap = go.Figure(go.Treemap(
labels=df_treemap['Label'],
parents=[""] * len(df_treemap),
values=df_treemap['Benefice'],
text=df_treemap['Benefice'].apply(lambda x: f"{x:,.0f} XOF".replace(',', ' ')),
textposition="middle center",
textfont=dict(size=12, color='#ffffff', family='Space Grotesk', weight=600),
marker=dict(
colors=df_treemap['Benefice'],
colorscale=[
[0, '#161b22'], # Très sombre
[0.2, '#21262d'], # Anthracite
[0.4, '#388bfd'], # Bleu moyen
[0.6, '#58a6ff'], # Bleu classique
[0.8, '#79c0ff'], # Bleu clair
[1, '#a5d6ff'] # Très clair
],
line=dict(color='#0d1117', width=1.5),
showscale=False
),
hovertemplate='<b>%{label}</b><br>Bénéfice: %{value:,.0f} XOF<br>Part: %{percentParent}<extra></extra>'
))
fig_treemap.update_layout(
plot_bgcolor='rgba(10, 14, 18, 0.9)',
paper_bgcolor='rgba(0, 0, 0, 0)',
font={'color': '#ffffff', 'family': 'Space Grotesk', 'size': 11},
height=350,
margin=dict(t=10, b=10, l=0, r=0)
)
st.plotly_chart(fig_treemap, use_container_width=True)
# Légende des variations sous le treemap
st.markdown("<h4 style='font-size: 0.8rem; color: #8b949e; margin-top: 24px; margin-bottom: 12px; font-family: JetBrains Mono; text-transform: uppercase;'>Variations mensuelles tactiques</h4>", unsafe_allow_html=True)
# Afficher les variations en grille 3 colonnes (Style Tactical Block)
variations_cols = st.columns(3)
for idx, row in df_treemap.iterrows():
col_index = idx % 3
with variations_cols[col_index]:
variation = row['Variation']
mois_nom = row['Mois_Nom'][:3].upper()
# Couleurs militaires strictes
if variation > 0:
couleur = "#54bd4b"
symbole = "▲"
elif variation < 0:
couleur = "#c0392b"
symbole = "▼"
else:
couleur = "#8b949e"
symbole = "→"
# Utilisation exclusive de JetBrains Mono et border-radius: 0
st.markdown(f"""
<div style='
background: rgba(15, 20, 26, 0.85);
border: 1px solid rgba(100, 120, 140, 0.25);
border-left: 3px solid {couleur};
padding: 12px 14px;
margin-bottom: 10px;
border-radius: 0;
font-family: "JetBrains Mono", monospace;
font-size: 0.8rem;
'>
<div style='display: flex; justify-content: space-between; align-items: center;'>
<span style='color: #8b949e; font-weight: 700; letter-spacing: 1px;'>{mois_nom}</span>
<span style='color: {couleur}; font-weight: 800;'>
{symbole} {abs(variation):.1f}%
</span>
</div>
</div>
""", unsafe_allow_html=True)
# Résumé
st.divider()
col_stat1, col_stat2, col_stat3 = st.columns(3)
with col_stat1:
st.metric(f"Total {annee_courante}", f"{total_benefices:,.0f} XOF".replace(',', ' '))
with col_stat2:
mois_max = df_treemap.loc[df_treemap['Benefice'].idxmax(), 'Mois_Nom'].upper()
montant_max = df_treemap['Benefice'].max()
st.metric("Meilleur mois", mois_max, f"{montant_max:,.0f} XOF".replace(',', ' '))
with col_stat3:
nb_mois_actifs = len(df_treemap)
st.metric("Mois actifs", f"{nb_mois_actifs}/12")
else:
st.info("[INFO] Aucun bénéfice enregistré pour créer la visualisation")
else:
st.warning("[ATTENTION] Colonnes 'Date_Deblocage' ou 'Cout_Credit' introuvables dans Prets_Master")