Spaces:
Running
Running
| import streamlit as st | |
| import pandas as pd | |
| import pydeck as pdk | |
| from geopy.geocoders import Nominatim | |
| from geopy.exc import GeocoderTimedOut | |
| import time | |
| import requests | |
| import datetime | |
| # --- CONSTANTES --- | |
| COLOR_BLUE_GOTHAM = "#25C1F7" | |
| COLOR_GREEN_ZONE = "#54BD4B" | |
| COLOR_YELLOW_LINE = "#FFD700" | |
| # === CSS SPÉCIFIQUE MAP DASHBOARD (Styles additionnels seulement) === | |
| def apply_map_dashboard_styles(): | |
| st.markdown(""" | |
| <style> | |
| /* === STYLES SPÉCIFIQUES MAP DASHBOARD === */ | |
| /* Wrapper pour isolation */ | |
| #map-dashboard-module { | |
| font-family: 'Space Grotesk', sans-serif; | |
| } | |
| /* Réduction des marges pour vue compacte */ | |
| #map-dashboard-module .block-container { | |
| padding-top: 1rem !important; | |
| padding-bottom: 0.5rem !important; | |
| } | |
| /* Headers compacts spécifiques à la map */ | |
| #map-dashboard-module h1 { | |
| font-size: 1.5rem !important; | |
| margin-bottom: 12px !important; | |
| margin-top: 0 !important; | |
| } | |
| #map-dashboard-module h2, | |
| #map-dashboard-module h3 { | |
| font-size: 0.9rem !important; | |
| margin-bottom: 6px !important; | |
| margin-top: 0 !important; | |
| } | |
| /* Colonnes encadrées style ops center */ | |
| #map-dashboard-module [data-testid="column"] { | |
| background: rgba(22, 27, 34, 0.6); | |
| border: 1px solid rgba(88, 166, 255, 0.3); | |
| border-radius: 4px; | |
| padding: 12px !important; | |
| margin: 2px; | |
| backdrop-filter: blur(10px); | |
| box-shadow: inset 0 0 20px rgba(88, 166, 255, 0.05); | |
| } | |
| /* Metrics ultra compacts pour la map */ | |
| #map-dashboard-module [data-testid="stMetric"] { | |
| background: rgba(13, 17, 23, 0.9); | |
| border: 1px solid rgba(48, 54, 61, 1); | |
| padding: 6px !important; | |
| margin: 0 !important; | |
| } | |
| #map-dashboard-module [data-testid="stMetric"] label { | |
| font-size: 0.65rem !important; | |
| margin-bottom: 2px !important; | |
| } | |
| #map-dashboard-module [data-testid="stMetric"] [data-testid="stMetricValue"] { | |
| font-size: 1.1rem !important; | |
| margin: 0 !important; | |
| } | |
| #map-dashboard-module [data-testid="stMetric"] [data-testid="stMetricDelta"] { | |
| font-size: 0.7rem !important; | |
| margin-top: 2px !important; | |
| } | |
| /* Expanders ultra compacts */ | |
| #map-dashboard-module .streamlit-expanderHeader { | |
| background: rgba(13, 17, 23, 0.9) !important; | |
| font-size: 0.75rem !important; | |
| padding: 6px 10px !important; | |
| margin-bottom: 3px !important; | |
| margin-top: 0 !important; | |
| } | |
| #map-dashboard-module .streamlit-expanderContent { | |
| background: rgba(13, 17, 23, 0.6); | |
| padding: 8px !important; | |
| margin-bottom: 3px !important; | |
| } | |
| /* Checkbox compacts */ | |
| #map-dashboard-module .stCheckbox { | |
| margin: 2px 0 !important; | |
| padding: 2px 0 !important; | |
| } | |
| #map-dashboard-module .stCheckbox label { | |
| font-size: 0.75rem !important; | |
| } | |
| /* Caption text compact */ | |
| #map-dashboard-module .caption, | |
| #map-dashboard-module [data-testid="stCaptionContainer"] { | |
| font-size: 0.7rem !important; | |
| margin: 2px 0 !important; | |
| line-height: 1.2 !important; | |
| } | |
| /* === ALERT BOXES PERSONNALISÉES === */ | |
| .map-alert-box { | |
| background: rgba(22, 27, 34, 0.9); | |
| border: 1px solid; | |
| border-radius: 3px; | |
| padding: 6px 8px !important; | |
| margin-bottom: 4px !important; | |
| font-size: 0.7rem; | |
| transition: all 0.2s ease; | |
| cursor: pointer; | |
| } | |
| .map-alert-box:hover { | |
| background: rgba(33, 38, 45, 1); | |
| transform: translateX(2px); | |
| } | |
| .map-alert-critical { | |
| border-color: #f85149; | |
| background: rgba(248, 81, 73, 0.15); | |
| } | |
| .map-alert-warning { | |
| border-color: #d29922; | |
| background: rgba(210, 153, 34, 0.15); | |
| } | |
| .map-alert-success { | |
| border-color: #3fb950; | |
| background: rgba(63, 185, 80, 0.15); | |
| } | |
| .map-alert-info { | |
| border-color: #58a6ff; | |
| background: rgba(88, 166, 255, 0.15); | |
| } | |
| /* Button compact pour la map */ | |
| #map-dashboard-module .stButton > button { | |
| font-size: 0.7rem !important; | |
| padding: 4px 10px !important; | |
| margin: 2px 0 !important; | |
| } | |
| /* Réduire l'espacement vertical dans la map */ | |
| #map-dashboard-module .element-container { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| } | |
| /* Divider compact */ | |
| #map-dashboard-module hr { | |
| margin: 8px 0 !important; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # --- FONCTION GÉOCODAGE --- | |
| def geocode_addresses(df_clients): | |
| """Géocodage via LocationIQ""" | |
| if 'Adresse' not in df_clients.columns or 'ID_Client' not in df_clients.columns: | |
| return pd.DataFrame() | |
| LOCATIONIQ_TOKEN = "pk.e1561a89e1ed2bc2ddeb3ee53fd88fb8" | |
| geocoded_data = [] | |
| for index, row in df_clients.iterrows(): | |
| address = str(row['Adresse']).strip() | |
| client_id = row['ID_Client'] | |
| try: | |
| url = "https://us1.locationiq.com/v1/search.php" | |
| params = { | |
| 'key': LOCATIONIQ_TOKEN, | |
| 'q': address, | |
| 'format': 'json', | |
| 'countrycodes': 'sn', | |
| 'limit': 1, | |
| 'accept-language': 'fr' | |
| } | |
| response = requests.get(url, params=params, timeout=10) | |
| if response.status_code == 200: | |
| data = response.json() | |
| if data and len(data) > 0: | |
| result = data[0] | |
| lat = float(result['lat']) | |
| lon = float(result['lon']) | |
| geocoded_data.append({ | |
| "ID": client_id, | |
| "Nom": row.get('Nom_Complet', 'Client Inconnu'), | |
| "Adresse": address, | |
| "lat": lat, | |
| "lon": lon, | |
| "Revenus": row.get('Revenus_Mensuels', 0) | |
| }) | |
| time.sleep(1.2) | |
| except Exception as e: | |
| pass | |
| return pd.DataFrame(geocoded_data) | |
| # --- FONCTION PRINCIPALE --- | |
| def show_map_dashboard(client, sheet_name): | |
| # Appliquer les styles spécifiques | |
| apply_map_dashboard_styles() | |
| # Wrapper pour isolation des styles | |
| st.markdown('<div id="map-dashboard-module">', unsafe_allow_html=True) | |
| st.markdown("<h1> GLOBAL CONTROL TOWER</h1>", unsafe_allow_html=True) | |
| # === CHARGEMENT DES DONNÉES === | |
| try: | |
| sh = client.open(sheet_name) | |
| ws_clients = sh.worksheet("Clients_KYC") | |
| ws_prets = sh.worksheet("Prets_Master") | |
| df_raw = pd.DataFrame(ws_clients.get_all_records()) | |
| df_prets = pd.DataFrame(ws_prets.get_all_records()) | |
| except Exception as e: | |
| st.error(f"Erreur de lecture : {e}") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| return | |
| if df_raw.empty: | |
| st.info("Aucune donnée client.") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| return | |
| # === 5 METRICS TEMPS RÉEL === | |
| col_m1, col_m2, col_m3, col_m4, col_m5 = st.columns(5) | |
| # Calculs depuis Prets_Master | |
| if not df_prets.empty: | |
| total_prets = len(df_prets) | |
| # Conversion sécurisée du montant en numérique | |
| if 'Montant_Capital' in df_prets.columns: | |
| montant_total = pd.to_numeric(df_prets['Montant_Capital'], errors='coerce').fillna(0).sum() | |
| else: | |
| montant_total = 0 | |
| prets_actifs = len(df_prets[df_prets['Statut'] == 'Actif']) if 'Statut' in df_prets.columns else 0 | |
| # Conversion sécurisée du taux en numérique | |
| if 'Taux_Hebdo' in df_prets.columns: | |
| taux_moyen = pd.to_numeric(df_prets['Taux_Hebdo'], errors='coerce').fillna(0).mean() | |
| else: | |
| taux_moyen = 0 | |
| prets_semaine = len(df_prets.tail(5)) if len(df_prets) >= 5 else len(df_prets) | |
| else: | |
| total_prets = montant_total = prets_actifs = taux_moyen = prets_semaine = 0 | |
| with col_m1: | |
| st.metric("PRÊTS ACTIFS", f"{prets_actifs}", f"+{prets_semaine} (7j)") | |
| with col_m2: | |
| st.metric("CAPITAL DÉPLOYÉ", f"{montant_total/1e6:.1f}M XOF", "+12%") | |
| with col_m3: | |
| st.metric("TAUX MOYEN", f"{taux_moyen:.1f}%", "-0.2%") | |
| with col_m4: | |
| st.metric("CLIENTS ACTIFS", f"{len(df_raw)}", "+3") | |
| with col_m5: | |
| st.metric("RECOUVREMENT", "94.2%", "+1.8%") | |
| st.markdown("---") | |
| # === GÉOCODAGE === | |
| with st.spinner(" Triangulation satellites..."): | |
| df_map = geocode_addresses(df_raw) | |
| if df_map.empty: | |
| st.warning("⚠️ Impossible de géolocaliser les adresses.") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| return | |
| # === LAYOUT PRINCIPAL : 3 COLONNES === | |
| col_left, col_center, col_right = st.columns([1, 3, 1]) | |
| # === COLONNE GAUCHE : CLIENTS === | |
| with col_left: | |
| st.markdown("### TARGETS") | |
| st.caption(f"{len(df_map)} clients géolocalisés") | |
| # Session state pour focus | |
| if 'focus_lat' not in st.session_state: | |
| st.session_state['focus_lat'] = df_map['lat'].mean() | |
| st.session_state['focus_lon'] = df_map['lon'].mean() | |
| st.session_state['focus_id'] = None | |
| for idx, row in df_map.iterrows(): | |
| client_key = f"client_{row['ID']}" | |
| with st.expander(f"🔹 {row['ID']}", expanded=False): | |
| st.caption(f"{row['Nom']}") | |
| st.caption(f" {row['Adresse'][:35]}...") | |
| st.caption(f" {row['Revenus']:,.0f} XOF") | |
| if st.button("LOCALISER", key=f"loc_{client_key}", use_container_width=True): | |
| st.session_state['focus_lat'] = row['lat'] | |
| st.session_state['focus_lon'] = row['lon'] | |
| st.session_state['focus_id'] = row['ID'] | |
| st.rerun() | |
| # === COLONNE CENTRALE : CARTE === | |
| with col_center: | |
| st.markdown("### TACTICAL MAP") | |
| # Préparer données clients | |
| clients_js = [] | |
| for _, row in df_map.iterrows(): | |
| clients_js.append({ | |
| 'lat': row['lat'], | |
| 'lng': row['lon'], | |
| 'id': row['ID'], | |
| 'nom': row['Nom'], | |
| 'adresse': row['Adresse'] | |
| }) | |
| # Focus | |
| focus_lat = st.session_state.get('focus_lat', df_map['lat'].mean()) | |
| focus_lng = st.session_state.get('focus_lon', df_map['lon'].mean()) | |
| # HTML Mapbox | |
| mapbox_html = f""" | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <script src='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'></script> | |
| <link href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css' rel='stylesheet' /> | |
| <style> | |
| body {{ margin: 0; padding: 0; }} | |
| #map {{ position: absolute; top: 0; bottom: 0; width: 100%; }} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="map"></div> | |
| <script> | |
| mapboxgl.accessToken = 'pk.eyJ1Ijoia2x5ZGUwNyIsImEiOiJjbWpwd3B4cWMwYTVqM2ZxeHcwM3FoMHF5In0.mgiTZbug_4eD3cSNgF2t5Q'; | |
| const map = new mapboxgl.Map({{ | |
| container: 'map', | |
| style: 'mapbox://styles/mapbox/satellite-streets-v12', | |
| center: [{focus_lng}, {focus_lat}], | |
| zoom: 15, | |
| pitch: 60, | |
| bearing: 0, | |
| antialias: true | |
| }}); | |
| map.on('load', () => {{ | |
| // Bâtiments 3D | |
| map.addLayer({{ | |
| 'id': '3d-buildings', | |
| 'source': 'composite', | |
| 'source-layer': 'building', | |
| 'filter': ['==', 'extrude', 'true'], | |
| 'type': 'fill-extrusion', | |
| 'minzoom': 14, | |
| 'paint': {{ | |
| 'fill-extrusion-color': '#aaa', | |
| 'fill-extrusion-height': ['get', 'height'], | |
| 'fill-extrusion-base': ['get', 'min_height'], | |
| 'fill-extrusion-opacity': 0.8 | |
| }} | |
| }}); | |
| const clients = {clients_js}; | |
| // Marqueurs clients (Losanges bleus) | |
| const clientSvg = ` | |
| <svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> | |
| <rect x="6" y="6" width="12" height="12" | |
| fill="#58a6ff" | |
| stroke="#c9d1d9" | |
| stroke-width="1.5" | |
| transform="rotate(45 12 12)" | |
| filter="drop-shadow(0 0 6px rgba(88, 166, 255, 0.8))"/> | |
| </svg>`; | |
| clients.forEach((client, index) => {{ | |
| const el = document.createElement('div'); | |
| el.innerHTML = clientSvg; | |
| el.style.cursor = 'pointer'; | |
| new mapboxgl.Marker({{element: el}}) | |
| .setLngLat([client.lng, client.lat]) | |
| .setPopup(new mapboxgl.Popup({{ offset: 25 }}).setHTML( | |
| `<div style="font-family: Space Grotesk; color: #c9d1d9; background: #161b22; padding: 8px; border-radius: 4px;"> | |
| <strong style="color: #58a6ff;">${{client.id}}</strong><br> | |
| <span style="font-size: 0.85rem;">${{client.nom}}</span><br> | |
| <span style="font-size: 0.75rem; color: #8b949e;">${{client.adresse.substring(0, 40)}}...</span> | |
| </div>` | |
| )) | |
| .addTo(map); | |
| }}); | |
| // Lignes de connexion entre clients | |
| if (clients.length > 1) {{ | |
| for (let i = 0; i < clients.length - 1; i++) {{ | |
| map.addLayer({{ | |
| 'id': 'line-' + i, | |
| 'type': 'line', | |
| 'source': {{ | |
| 'type': 'geojson', | |
| 'data': {{ | |
| 'type': 'Feature', | |
| 'geometry': {{ | |
| 'type': 'LineString', | |
| 'coordinates': [ | |
| [clients[i].lng, clients[i].lat], | |
| [clients[i+1].lng, clients[i+1].lat] | |
| ] | |
| }} | |
| }} | |
| }}, | |
| 'paint': {{ | |
| 'line-color': '#3fb950', | |
| 'line-width': 1.5, | |
| 'line-opacity': 0.4 | |
| }} | |
| }}); | |
| }} | |
| }} | |
| }}); | |
| map.addControl(new mapboxgl.NavigationControl(), 'top-right'); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| st.components.v1.html(mapbox_html, height=500) | |
| # === COLONNE DROITE : ALERTES === | |
| with col_right: | |
| st.markdown("### LIVE FEED") | |
| now = datetime.datetime.now() | |
| alerts = [ | |
| { | |
| 'type': 'critical', | |
| 'title': 'SEUIL ATTEINT', | |
| 'message': 'NPL > 15% détecté', | |
| 'time': (now - datetime.timedelta(minutes=2)).strftime('%H:%M') | |
| }, | |
| { | |
| 'type': 'warning', | |
| 'title': 'ANOMALIE DÉTECTÉE', | |
| 'message': 'CA mensuel -12%', | |
| 'time': (now - datetime.timedelta(minutes=15)).strftime('%H:%M') | |
| }, | |
| { | |
| 'type': 'success', | |
| 'title': 'VALIDATION', | |
| 'message': f'{df_map.iloc[0]["ID"]} approuvé', | |
| 'time': (now - datetime.timedelta(minutes=23)).strftime('%H:%M') | |
| }, | |
| { | |
| 'type': 'info', | |
| 'title': 'RECOMMANDATION', | |
| 'message': 'Augmenter taux +0.5%', | |
| 'time': (now - datetime.timedelta(hours=1)).strftime('%H:%M') | |
| }, | |
| { | |
| 'type': 'warning', | |
| 'title': 'RETARD PAIEMENT', | |
| 'message': '3 clients en J+7', | |
| 'time': (now - datetime.timedelta(hours=2)).strftime('%H:%M') | |
| }, | |
| ] | |
| for alert in alerts: | |
| st.markdown(f""" | |
| <div class="map-alert-box map-alert-{alert['type']}"> | |
| <div style="display: flex; justify-content: space-between; margin-bottom: 2px;"> | |
| <strong style="color: #c9d1d9; font-size: 0.7rem;">{alert['title']}</strong> | |
| <span style="color: #8b949e; font-size: 0.65rem;">{alert['time']}</span> | |
| </div> | |
| <div style="color: #8b949e; font-size: 0.7rem; line-height: 1.2;">{alert['message']}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # === STATS ANSD === | |
| st.markdown("---") | |
| st.markdown("### INDICATEURS MACROÉCONOMIQUES ANSD") | |
| col1, col2, col3 = st.columns(3) | |
| with col1: | |
| st.metric("Croissance PIB", "5.2%", "+0.3%") | |
| st.metric("Taux BCEAO", "3.0%", "-0.5%") | |
| st.metric("NPL Bancaires", "14.8%", "+1.2%") | |
| with col2: | |
| st.metric("Emploi Informel", "68%", "-2%") | |
| st.metric("Dette Publique", "72% PIB", "+3%") | |
| st.metric("Transferts Diaspora", "2.1B USD", "+5%") | |
| with col3: | |
| st.metric("Inflation Alimentaire", "8.3%", "+1.5%") | |
| st.metric("Taux EUR/USD", "1.08", "-0.02") | |
| st.metric("Production Agricole", "92 idx", "-8 idx") | |
| # Fermeture du wrapper | |
| st.markdown('</div>', unsafe_allow_html=True) |