File size: 48,852 Bytes
6e03046
 
397b23d
6e03046
 
 
e07d8ff
6ff8ef6
e07d8ff
6e03046
 
 
 
397b23d
3d15e49
56277f7
47776fe
c20487a
397b23d
de5e25f
 
e07d8ff
c638843
54b48aa
6e03046
 
 
 
 
 
 
 
 
8fd48f1
 
 
6ff8ef6
 
 
8fd48f1
 
397b23d
 
 
8fd48f1
 
e07d8ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c73b85c
 
 
 
 
 
ba77684
8fd48f1
 
 
94b3414
 
 
 
 
8fd48f1
 
de5e25f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e03046
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fd48f1
397b23d
6e03046
 
 
 
 
 
 
 
 
397b23d
6e03046
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397b23d
 
 
 
6e03046
 
cf04e96
 
 
 
 
 
 
 
 
 
 
 
 
ba77684
cf04e96
 
 
 
ba77684
cf04e96
 
 
 
 
 
 
 
 
397b23d
cf04e96
 
 
 
 
 
 
 
 
 
397b23d
 
 
 
cf04e96
 
6e03046
 
 
8fd48f1
 
6e03046
 
 
 
4284ba6
 
 
44bf241
4284ba6
c20487a
 
 
 
 
397b23d
c20487a
e3cb468
 
c20487a
397b23d
4284ba6
 
 
 
 
c20487a
4284ba6
 
 
397b23d
4284ba6
397b23d
4284ba6
 
397b23d
c20487a
 
 
3d15e49
4284ba6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44bf241
4284ba6
 
6e03046
 
 
 
 
8fd48f1
 
 
 
 
6e03046
f1c8fc8
6e03046
8fd48f1
 
 
 
 
 
 
 
94b3414
 
 
 
 
8fd48f1
 
c4a01d3
94b3414
8fd48f1
 
 
 
 
 
 
94b3414
 
ba77684
94b3414
8fd48f1
 
ba77684
 
 
4284ba6
 
7ec09c4
4284ba6
8fd48f1
94b3414
8fd48f1
ba77684
94b3414
ba77684
 
8fd48f1
ba77684
 
 
 
3d15e49
e07d8ff
 
 
 
2278cb0
e07d8ff
 
 
 
 
 
 
2278cb0
e07d8ff
2278cb0
e07d8ff
f40f505
 
 
 
 
 
 
 
 
4d00d77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495d60d
4d00d77
 
 
5b425ba
 
 
 
 
 
 
 
 
e07d8ff
 
 
 
 
5b425ba
e07d8ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495d60d
e07d8ff
 
5b425ba
e07d8ff
5b425ba
4d00d77
3d15e49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d00d77
3d15e49
 
 
495d60d
3d15e49
 
 
 
 
 
 
8fd48f1
397b23d
 
 
c638843
 
 
 
 
 
 
 
ba77684
495d60d
c638843
 
495d60d
6e03046
8fd48f1
94b3414
 
8fd48f1
 
 
 
 
397b23d
 
 
 
 
 
94b3414
 
 
397b23d
 
 
94b3414
 
8fd48f1
397b23d
e07d8ff
8fd48f1
4284ba6
8fd48f1
ba77684
94b3414
e07d8ff
 
 
 
 
8fd48f1
 
54b48aa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25c2bcc
 
 
 
 
 
2308a61
 
 
 
 
 
25c2bcc
 
 
 
 
 
 
 
 
54b48aa
 
 
 
cf04e96
 
 
 
495d60d
6e03046
4284ba6
 
47776fe
 
 
 
 
 
 
 
 
 
 
 
 
397b23d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e07d8ff
397b23d
 
 
 
 
e07d8ff
 
 
 
 
397b23d
 
 
 
 
 
 
 
 
 
 
47776fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397b23d
 
 
47776fe
de5e25f
47776fe
 
 
 
 
 
4284ba6
 
397b23d
47776fe
56277f7
 
 
 
 
 
 
 
 
 
 
 
 
 
cf04e96
 
 
 
 
 
 
4284ba6
 
 
 
397b23d
 
 
de5e25f
 
 
 
c638843
495d60d
de5e25f
cf04e96
 
c638843
 
de5e25f
c638843
de5e25f
 
4284ba6
 
 
c638843
4284ba6
495d60d
4284ba6
 
 
 
 
 
 
 
495d60d
4284ba6
c638843
4284ba6
 
 
 
 
 
 
 
 
 
 
 
495d60d
397b23d
 
 
4284ba6
 
 
 
 
 
 
 
 
8cbb101
495d60d
4284ba6
 
8cbb101
4284ba6
 
 
 
8cbb101
4284ba6
495d60d
4284ba6
 
cf04e96
6e03046
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
import streamlit as st
import pandas as pd
from datetime import datetime, date, timedelta
import os
import requests
import base64
import html
import re
import streamlit.components.v1 as components

import sys
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
from DocumentGen.AutoPDFGeneration import generer_contrat_pret, generer_reconnaissance_dette, generer_contrat_caution
from Analytics.AnalyseFinance import generer_tableau_amortissement, TAXE_TRANSFERT
from Analytics.AnalyseRepayment import get_client_repayment_stats
from Analytics import penalites_retard
from core.id_generator import generate_next_id
from core.sheets_cache import get_cached_dfs_batch, clear_sheets_cache
from core.email_client import get_admin_email
from core import signature as sg
from core import signature_service as svc
from core.geoip_client import resolve_ip_location
from core.ui_guards import rendre_bouton_protege, marquer_fait, liberer
from modules.jasmine_widget import render_scoped_widget

def _get_credential(key):
    val = os.environ.get(key)
    if val: return val
    try:
        if key in st.secrets: return st.secrets[key]
    except Exception: pass
    return None

def clean_num(val):
    """Nettoie toute chaîne de caractères pour en faire un float pur."""
    if isinstance(val, str):
        # Sheets en locale francaise : "50 000" porte une espace insecable (U+00A0 / U+202F),
        # que replace(' ') ne retire pas -> float() echouait et le montant devenait 0.
        val = re.sub(r"\s+", "", val).replace(',', '.').replace('XOF', '').replace('%', '')
    try:
        return float(val)
    except (ValueError, TypeError) as e:
        from core.errors import log_swallowed
        log_swallowed(f"clean_num : valeur illisible (valeur : {val!r})", e)
        return 0.0


def _parse_coordinate_pair(latitude, longitude):
    """Valide une paire et restaure une décimale perdue de façon cohérente."""
    try:
        latitude = float(str(latitude).strip().replace(",", "."))
        longitude = float(str(longitude).strip().replace(",", "."))
    except (TypeError, ValueError):
        return None, None

    both_out_of_range = abs(latitude) > 90 and abs(longitude) > 180
    shifts = 0
    while both_out_of_range and (abs(latitude) > 90 or abs(longitude) > 180) and shifts < 6:
        latitude /= 10
        longitude /= 10
        shifts += 1

    if abs(latitude) > 90 or abs(longitude) > 180:
        return None, None
    return latitude, longitude

def parse_french_locale_float(val, max_logical_value=None):
    """Lit un decimal du Sheet. La virgule francaise est desormais lue correctement a la
    source (core/gspread_fr.py) : plus de division par 10 "tant que la valeur depasse le
    maximum logique", qui reduisait a tort un taux legitime de 24,11 % en 2,41 %.
    `max_logical_value` est conserve pour compatibilite et ignore."""
    return round(clean_num(val), 2)

def get_col_safe(row, partial_name, default="N/A"):
    """Trouve une colonne même si l'utilisateur a changé son nom dans Google Sheets."""
    for col in row.index:
        if partial_name.lower() == col.lower(): return row[col]
    for col in row.index:
        if col.lower().startswith(partial_name.lower()): return row[col]
    for col in row.index:
        if partial_name.lower() in col.lower(): return row[col]
    return default

def lancer_signature_octroi(client_gspread, sheet_name, req_data, client_data, garant_data, df_amort, prt_id, id_client):
    """Envoie les liens de signature (emprunteur + garant). Le pret reste EN_ATTENTE_SIGNATURE jusqu'a
    ce que tous aient signe. Retourne (ok, detail)."""
    sh = client_gspread.open(sheet_name)
    loan = {**req_data, "ID_Pret": prt_id, "ID_Client": id_client,
            "ID_Garant": (garant_data or {}).get("ID_Garant", req_data.get("ID_Garant", ""))}
    snapshot = sg.construire_snapshot(loan, client_data, garant_data, df_amort)
    try:
        resultats = svc.lancer_signature_pret(sh, id_pret=prt_id, source=svc.SOURCE_MASTER, snapshot=snapshot)
    except sg.SignatureConfigError as exc:
        return False, str(exc)
    echecs = [f"{r['role'].title()} : {r['detail']}" for r in resultats if not r["ok"]]
    return (not echecs), " ; ".join(echecs)


def approuver_et_envoyer(req_data, client_data, garant_data, df_amort, prt_id):
    """Génère les PDF et fait deux tirs séparés (Client+CC Garant) et (Admin)"""
    pdf_contrat = generer_contrat_pret(req_data, client_data, df_amort)
    pdf_dette = generer_reconnaissance_dette(req_data, client_data)
    pdf_caution = generer_contrat_caution(req_data, garant_data) if garant_data else None

    api_key = _get_credential("BREVO_API_KEY")
    sender_email = _get_credential("SENDER_EMAIL")
    if not api_key or not sender_email: return False
    
    html = f"""
    <html><body style="font-family: 'Courier New', monospace; background-color: #0a0e12; color: #c9d1d9; padding: 20px;">
        <div style="border: 1px solid #54bd4b; padding: 20px; background-color: rgba(10, 14, 18, 0.8);">
            <h2 style="color: #54bd4b;">VOTRE PRET EST APPROUVE</h2>
            <p>Dossier officiel : <strong>{prt_id}</strong></p>
            <p>Capital validé : {req_data['Montant_Capital']:,} XOF</p>
            <p>Veuillez trouver vos documents contractuels en pièces jointes. Les fonds vous seront transférés sous peu.</p>
        </div>
    </body></html>
    """
    url = "https://api.brevo.com/v3/smtp/email"
    headers = {"accept": "application/json", "api-key": api_key, "content-type": "application/json"}
    
    payload_client = {
        "sender": {"email": sender_email, "name": "Vortex Finance"},
        "to": [{"email": client_data.get('Email',''), "name": client_data.get('Nom_Complet','')}],
        "bcc": [{"email": get_admin_email(), "name": "Klyde (Admin Vortex)"}],
        "subject": f"CONTRAT DE PRET APPROUVE - {prt_id}",
        "htmlContent": html, "attachment": []
    }
    
    if garant_data and "@" in str(garant_data.get('Email', '')):
        payload_client["cc"] = [{"email": garant_data['Email'], "name": garant_data['Nom_Complet']}]

    payload_admin = {
        "sender": {"email": sender_email, "name": "Vortex Finance"},
        "to": [{"email": get_admin_email(), "name": "Admin Vortex"}],
        "subject": f"[COPIE SECRETE ADMIN] OCTROI - {prt_id}",
        "htmlContent": html, "attachment": []
    }

    def attach(pdf_obj, name):
        if not pdf_obj: return
        raw = pdf_obj.getvalue() if hasattr(pdf_obj, 'getvalue') else pdf_obj
        b64 = base64.b64encode(raw).decode('utf-8')
        payload_client["attachment"].append({"name": name, "content": b64})
        payload_admin["attachment"].append({"name": name, "content": b64})

    attach(pdf_contrat, f"Contrat_{prt_id}.pdf")
    attach(pdf_dette, f"Dette_{prt_id}.pdf")
    attach(pdf_caution, f"Caution_{prt_id}.pdf")

    try:
        requests.post(url, json=payload_client, headers=headers, timeout=15)
        requests.post(url, json=payload_admin, headers=headers, timeout=15)
        return True
    except Exception as e:
        from core.errors import log_swallowed
        log_swallowed(f"Envoi de l'email d'octroi pour {prt_id} echoue", e)
        return False


def refuser_et_envoyer(req_data, client_data, garant_data, motif_refus):
    """Envoie un e-mail de refus au client, avec copie au garant et BCC à l'admin"""
    api_key = _get_credential("BREVO_API_KEY")
    sender_email = _get_credential("SENDER_EMAIL")
    if not api_key or not sender_email: return False

    req_id = req_data.get('ID_Pret', 'DOSSIER')
    
    html = f"""
    <html><body style="font-family: 'Courier New', monospace; background-color: #0a0e12; color: #c9d1d9; padding: 20px;">
        <div style="border: 1px solid #e74c3c; padding: 20px; background-color: rgba(10, 14, 18, 0.8);">
            <h2 style="color: #e74c3c;">MISE À JOUR DE VOTRE DEMANDE DE PRÊT</h2>
            <p>Dossier : <strong>{req_id}</strong></p>
            <p>Nous regrettons de vous informer qu'après analyse par la direction, votre demande de financement n'a pas pu être retenue.</p>
            <div style="background: rgba(255,255,255,0.05); padding: 15px; border-left: 3px solid #e74c3c; margin-top: 20px;">
                <p><strong>Motif de la décision :</strong></p>
                <p>{motif_refus}</p>
            </div>
            <p style="font-size: 0.8rem; color: #7a8a9a; margin-top: 20px;">Pour toute question, veuillez contacter le support Vortex Finance.</p>
        </div>
    </body></html>
    """
    url = "https://api.brevo.com/v3/smtp/email"
    headers = {"accept": "application/json", "api-key": api_key, "content-type": "application/json"}
    
    payload_client = {
        "sender": {"email": sender_email, "name": "Vortex Finance"},
        "to": [{"email": client_data.get('Email',''), "name": client_data.get('Nom_Complet','')}],
        "bcc": [{"email": get_admin_email(), "name": "Klyde (Admin Vortex)"}],
        "subject": f"Mise à jour concernant votre demande - {req_id}",
        "htmlContent": html
    }
    
    if garant_data and "@" in str(garant_data.get('Email', '')):
        payload_client["cc"] = [{"email": garant_data['Email'], "name": garant_data['Nom_Complet']}]

    try:
        requests.post(url, json=payload_client, headers=headers, timeout=15)
        return True
    except Exception as e:
        from core.errors import log_swallowed
        log_swallowed(f"Envoi de l'email de refus pour {req_id} echoue", e)
        return False


def show_request_loans(client_gspread, sheet_name):
    st.markdown("""<style>
    .req-card { background: rgba(15,20,26,0.9); border-left: 4px solid #f39c12; padding: 15px; margin-bottom: 15px; font-family: 'JetBrains Mono', monospace;}
    .jasmine-box { background: rgba(88,166,255,0.05); border: 1px solid rgba(88,166,255,0.3); padding: 15px; font-size: 0.8rem; margin-top: 15px; line-height:1.6;}
    .kyc-box { border: 1px solid rgba(100,120,140,0.3); padding: 10px; font-size: 0.75rem; background: rgba(10,14,18,0.5); }
    </style>""", unsafe_allow_html=True)
    
    st.header("VALIDATION DES DEMANDES (BACK-OFFICE)")
    
    # ============================================================================
    # BLOC D'INTERCEPTION 429 (CHARGEMENT)
    # ============================================================================
    try:
        sh = client_gspread.open(sheet_name)
        ws_req = sh.worksheet("Prets_Requests")

        # Les 4 feuilles necessaires a cette page sont lues en UN SEUL appel API
        # (values_batch_get) au lieu de 4 appels separes, pour diviser par 4 la
        # consommation de quota de cette page a chaque rerun (clic, expander...).
        try:
            _dfs = get_cached_dfs_batch(
                sh, sheet_name, ["Prets_Requests", "Clients_KYC", "Garants_KYC", "Remboursements"],
                numericise_ignore_columns={"Prets_Requests": {"Taux_Endettement"}},
            )
        except Exception as inner_e:
            if "429" in str(inner_e) or "Quota" in str(inner_e):
                raise inner_e # On remonte l'erreur 429 au gestionnaire principal
            st.info("La file d'attente est vide.")
            return

        df_req = _dfs["Prets_Requests"]
        if df_req.empty or 'Statut' not in df_req.columns:
            st.info("Aucune demande en attente actuellement.")
            return

        df_attente = df_req[df_req['Statut'] == 'EN_ATTENTE']
        if df_attente.empty:
            st.info("Aucune nouvelle demande en attente de validation.")
            return

        df_clients = _dfs["Clients_KYC"]
        df_garants = _dfs["Garants_KYC"]
        df_remb = _dfs["Remboursements"]

    except Exception as e:
        error_str = str(e)
        if "429" in error_str or "Quota exceeded" in error_str:
            st.markdown("""
            <div style="background: rgba(243, 156, 18, 0.08); border: 1px solid rgba(243, 156, 18, 0.3); border-left: 4px solid #f39c12; padding: 14px 18px; margin-bottom: 16px; font-family: 'JetBrains Mono', monospace; border-radius: 0;">
                <div style="color: #f39c12; font-weight: 800; font-size: 0.75rem; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 6px;">
                    [ AVERTISSEMENT TACTIQUE : CODE 429 ]
                </div>
                <div style="color: #a8b8c8; font-size: 0.75rem; line-height: 1.6;">
                    SATURATION TEMPORAIRE DES TRANSMISSIONS VERS LE CENTRE DE DONNEES.<br>
                    Le systeme Google limite actuellement les requetes pour eviter la surcharge.<br>
                    <span style="color:#f39c12; font-weight: 700;">> ACTION REQUISE :</span> Maintenez la position et patientez environ 60 secondes avant de relancer.
                </div>
            </div>
            """, unsafe_allow_html=True)
        else:
            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: 14px 18px; margin-bottom: 16px; font-family: 'JetBrains Mono', monospace; border-radius: 0;">
                <div style="color: #c0392b; font-weight: 800; font-size: 0.75rem; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 6px;">
                    [ ERREUR SYSTEME CRITIQUE ]
                </div>
                <div style="color: #a8b8c8; font-size: 0.75rem; line-height: 1.6;">
                    ECHEC DE CONNEXION : {error_str}
                </div>
            </div>
            """, unsafe_allow_html=True)
        return
    # ============================================================================

    
    for idx, req in df_attente.iterrows():
        with st.container():
            st.markdown(f'<div class="req-card">', unsafe_allow_html=True)
            col1, col2 = st.columns([3, 1])
            
            id_pret = get_col_safe(req, 'ID_Pret')
            nom_client = get_col_safe(req, 'Nom_Complet')
            col1.markdown(f"### {id_pret} | {nom_client}")
            col1.caption(f"{get_col_safe(req, 'Offre')} - {get_col_safe(req, 'Motif')} - {get_col_safe(req, 'Date_Creation')}")
            
            with st.expander("VOIR LE DOSSIER COMPLET & RAPPORTS JASMINE"):
                
                # --- 1. PROFIL FINANCIER DU CLIENT ---
                st.markdown("#### PROFIL DU CLIENT (KYC)")
                c_data = df_clients[df_clients['ID_Client'] == get_col_safe(req, 'ID_Client')]
                if not c_data.empty:
                    c_data = c_data.iloc[0].to_dict()
                    rev = clean_num(c_data.get('Revenus_Mensuels', 0))
                    chg = clean_num(c_data.get('Charges_Estimees', 0))
                    rav = rev - chg
                    
                    rav_str = f"{int(rav):,}".replace(",", " ")
                    rev_str = f"{int(rev):,}".replace(",", " ")
                    chg_str = f"{int(chg):,}".replace(",", " ")
                    
                    st.markdown(f"""
                    <div class="kyc-box">
                        <b>Profession :</b> {html.escape(str(c_data.get('Statut_Pro', 'N/A')))} | <b>Secteur :</b> {html.escape(str(c_data.get('Secteur_Activite', 'N/A')))} <br>
                        <b>Revenus :</b> {rev_str} XOF | <b>Charges :</b> {chg_str} XOF | <b>Reste à vivre :</b> <span style="color:#54bd4b">{rav_str} XOF</span>
                    </div>
                    """, unsafe_allow_html=True)
                else:
                    c_data = {}
                    st.warning("Données KYC du client introuvables.")

                # --- 2. CONFIGURATION DU PRET ---
                st.markdown("<br>", unsafe_allow_html=True)
                st.markdown("#### DONNÉES DU PRÊT")
                c1, c2, c3, c4 = st.columns(4)
                
                capital = clean_num(get_col_safe(req, 'Montant_Capital'))
                total = clean_num(get_col_safe(req, 'Montant_Total'))
                cout_credit = clean_num(get_col_safe(req, 'Cout_Credit'))
                nb_vers = int(clean_num(get_col_safe(req, 'Nb_Versements')))
                
                # CORRECTION LOCALE
                taux = parse_french_locale_float(get_col_safe(req, 'Taux_Hebdo'), 20)          
                endettement = round(clean_num(get_col_safe(req, 'Taux_Endettement')), 2)
                duree = parse_french_locale_float(get_col_safe(req, 'Duree_Semaines'), 104)      
                
                c1.metric("Capital Demandé", f"{int(capital):,} XOF".replace(',', ' '))
                c1.metric("Taux IA", f"{taux}%")
                
                c2.metric("Montant Total", f"{int(total):,} XOF".replace(',', ' '))
                c2.metric("Bénéfice (Coût Crédit)", f"{int(cout_credit):,} XOF".replace(',', ' '))
                
                c3.metric("Type de Prêt", str(get_col_safe(req, 'Type_Pret')))
                c3.metric("Nb Versements", str(nb_vers))
                
                c4.metric("Durée", f"{duree} sem")
                c4.metric("Endettement", f"{endettement}%")

                score_remboursement = get_col_safe(req, 'Score_Reussite_Remboursement', None)
                validation_score = str(get_col_safe(req, 'Validation_Jev_Remboursement', '')).strip().upper()
                score_vide = score_remboursement is None or str(score_remboursement).strip().upper() in {"", "N/A", "NAN", "NONE"}
                score_num = clean_num(score_remboursement) if not score_vide else None
                if validation_score in ("VALIDE", "MODELE_VALIDE") and score_num is not None and 0 <= score_num <= 100:
                    n_modele = get_col_safe(req, 'N_Echantillons_Modele', 'N/A')
                    n_validation = get_col_safe(req, 'N_Validation_Modele', 'N/A')
                    st.markdown("#### ESTIMATION DE REMBOURSEMENT (AIDE À LA DÉCISION)")
                    score_col, sample_col, validation_col = st.columns(3)
                    score_col.metric("Remboursement intégral et à temps", f"{int(round(score_num))}/100")
                    sample_col.metric("Prêts historiques matures", str(n_modele))
                    validation_col.metric("Prêts du test chronologique", str(n_validation))
                    st.caption("Estimation statistique d'un modèle ayant passé ses contrôles de qualité (test chronologique). Elle ne garantit pas le remboursement et ne constitue pas une décision d'octroi.")
                elif validation_score:
                    st.caption("Estimation de remboursement non affichée : le modèle n'est pas assez fiable (historique insuffisant ou qualité de test trop faible).")

                # Duree de l'analyse IA au moment de la simulation (ecrite par le simulateur,
                # libelles fixes : aucun texte saisi par le client).
                duree_ia = get_col_safe(req, 'Duree_Analyse_IA_s', None)
                if duree_ia is not None and str(duree_ia).strip().upper() not in {"", "N/A", "NAN", "NONE"}:
                    st.caption(f"Durée de l'analyse IA lors de la simulation : {clean_num(duree_ia):.1f} s")
                    detail_ia = str(get_col_safe(req, 'Detail_Analyse_IA', '')).strip()
                    if detail_ia and detail_ia.upper() not in {"N/A", "NAN", "NONE"}:
                        st.caption(f"Détail : {detail_ia}")

                # --- 2bis. DÉTAIL DES FRAIS (composition du taux/coût) ---
                # Champs numeriques ecrits par le simulateur (jamais de texte libre
                # influence par le client) : safe a afficher directement, contrairement
                # a Score_Jasmine ci-dessous qui doit rester echappe.
                frais_transfert   = get_col_safe(req, 'Frais_Transfert_XOF', None)
                taux_risque_brut  = get_col_safe(req, 'Taux_Risque_Brut_Pct', None)
                marge_appliquee_v = get_col_safe(req, 'Marge_Appliquee_Pct', None)
                majoration_retard = get_col_safe(req, 'Majoration_Retard_Pct', None)
                nb_occ_retard     = get_col_safe(req, 'Nb_Occurrences_Retard', None)

                st.markdown("<br>", unsafe_allow_html=True)
                st.markdown("#### DÉTAIL DES FRAIS")
                vide = (None, "", "N/A")
                if frais_transfert in vide and taux_risque_brut in vide:
                    st.caption("Détail des frais indisponible pour cette demande (soumise avant l'ajout de ce suivi).")
                else:
                    f1, f2, f3, f4 = st.columns(4)
                    f1.metric(
                        "Frais de transfert",
                        f"{int(clean_num(frais_transfert)):,} XOF".replace(',', ' ') if frais_transfert not in vide else "N/A",
                    )
                    f2.metric("Risque brut (IA)", f"{clean_num(taux_risque_brut):.2f}%" if taux_risque_brut not in vide else "N/A")
                    f3.metric("Marge appliquée", f"{clean_num(marge_appliquee_v):.2f}%" if marge_appliquee_v not in vide else "N/A")
                    majoration_val = clean_num(majoration_retard) if majoration_retard not in vide else 0.0
                    f4.metric("Majoration retard pardonné", f"+{majoration_val:.2f}%" if majoration_val > 0 else "Aucune")
                    if nb_occ_retard not in vide and clean_num(nb_occ_retard) > 0:
                        st.caption(
                            f"[ATTENTION] Ce client a encore {int(clean_num(nb_occ_retard))} occurrence(s) de retard "
                            "pardonné en attente de facturation après cette demande."
                        )

                # --- 2bis. ORIGINE DE LA DEMANDE (IP + position, anti-fraude) ---
                # IP et position navigateur ne garantissent rien de facon absolue (VPN,
                # position refusee...) : c'est un faisceau d'indices a croiser avec
                # l'adresse declaree du client, pas une preuve a elle seule.
                ip_demande = get_col_safe(req, 'IP_Demande', None)
                lat_demande = get_col_safe(req, 'Lat_Demande', None)
                lon_demande = get_col_safe(req, 'Lon_Demande', None)
                source_position = get_col_safe(req, 'Source_Position_Demande', None)
                if ip_demande not in vide or lat_demande not in vide:
                    source_label = {
                        "triangulation_navigateur": "POSITION NAVIGATEUR",
                        "geocodage_ip": "LOCALISATION APPROXIMATIVE PAR IP",
                    }.get(str(source_position).split(" ")[0], source_position or "SOURCE INCONNUE")
                    lat_f = lon_f = None
                    if lat_demande not in vide and lon_demande not in vide:
                        lat_f, lon_f = _parse_coordinate_pair(lat_demande, lon_demande)

                    if (lat_f is None or lon_f is None) and ip_demande not in vide:
                        ip_location = resolve_ip_location(str(ip_demande).strip())
                        ip_lat, ip_lon = _parse_coordinate_pair(
                            ip_location.get("lat"), ip_location.get("lon")
                        )
                        if ip_lat is not None and ip_lon is not None:
                            lat_f, lon_f = ip_lat, ip_lon
                            source_label = "LOCALISATION APPROXIMATIVE PAR IP"

                    ip_label = html.escape(str(ip_demande)) if ip_demande not in vide else "NON DISPONIBLE"
                    st.markdown(f"""
                    <div style="background:rgba(10,14,18,0.92); border:1px solid rgba(255,79,160,0.35); border-left:4px solid #ff4f9a; padding:14px 18px; margin:12px 0 0; font-family:'JetBrains Mono',monospace;">
                      <div style="color:#ff4f9a; font-weight:800; letter-spacing:1.5px; text-transform:uppercase; font-size:0.78rem; margin-bottom:10px;">ORIGINE DE LA DEMANDE</div>
                      <div style="color:#a8b8c8; font-size:0.82rem; line-height:1.8;">
                        ADRESSE IP : <b style="color:#e6edf3;">{ip_label}</b><br>
                        LOCALISATION : <b style="color:#ff8cbd;">{html.escape(str(source_label))}</b>
                      </div>
                    </div>
                    """, unsafe_allow_html=True)

                    if lat_f is not None and lon_f is not None:
                        map_url = f"https://maps.google.com/maps?q={lat_f:.6f},{lon_f:.6f}&z=14&output=embed"
                        maps_link = f"https://www.google.com/maps?q={lat_f:.6f},{lon_f:.6f}"
                        st.markdown(f"""
                        <a href="{maps_link}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:10px 0 6px; padding:8px 12px; border:1px solid #ff4f9a; color:#ff8cbd; font-family:'JetBrains Mono',monospace; font-size:0.72rem; letter-spacing:1px; text-decoration:none;">[POSITION] OUVRIR LA POSITION DANS GOOGLE MAPS</a>
                        """, unsafe_allow_html=True)
                        components.iframe(map_url, height=320, scrolling=False)
                    else:
                        st.caption("Position cartographique indisponible. L’adresse IP est affichée, mais IPINFO_API_KEY doit être configurée pour tenter une localisation approximative.")

                # --- 2ter. HISTORIQUE DE REMBOURSEMENT (RETARDS PASSÉS) ---
                id_client = get_col_safe(req, 'ID_Client')
                remb_stats = get_client_repayment_stats(df_remb, id_client)
                if remb_stats["nb_echeances"] > 0:
                    couleur_histo = "#c0392b" if remb_stats["ratio_retard"] > 30 else ("#f0b90b" if remb_stats["ratio_retard"] > 0 else "#54bd4b")
                    st.markdown(f"""
                    <div style="background: rgba(10, 14, 18, 0.9); border: 1px solid rgba(80, 100, 120, 0.3); border-left: 4px solid {couleur_histo}; padding: 14px 18px; margin-top: 10px; font-family: 'JetBrains Mono', monospace;">
                        <div style="color:{couleur_histo}; font-weight: 800; letter-spacing: 1px; text-transform: uppercase; font-size: 0.75rem; margin-bottom: 6px;">HISTORIQUE DE REMBOURSEMENT (TOUS PRÊTS)</div>
                        <div style="color:#a8b8c8; font-size: 0.85rem; line-height: 1.6;">
                            Échéances passées : <b style="color:#e6edf3;">{remb_stats['nb_echeances']}</b> &nbsp;|&nbsp;
                            En retard : <b style="color:{couleur_histo};">{remb_stats['nb_retards']} ({remb_stats['ratio_retard']:.0f}%)</b> &nbsp;|&nbsp;
                            Retard moyen : <b style="color:#e6edf3;">{remb_stats['retard_moyen_jours']:.1f} j</b> &nbsp;|&nbsp;
                            Pire retard : <b style="color:#e6edf3;">{remb_stats['pire_retard_jours']:.0f} j</b>
                        </div>
                    </div>
                    """, unsafe_allow_html=True)
                else:
                    st.caption("Aucun historique de remboursement trouvé pour ce client (premier prêt ou table Remboursements vide).")

                # --- 2quater. ALERTE RISQUE ENDETTEMENT (INFORMATIVE, N'EMPÊCHE PAS LA DÉCISION) ---
                if endettement > 130:
                    st.markdown("""
                    <div style="background: rgba(240, 185, 11, 0.06); border: 1px solid rgba(240, 185, 11, 0.4); border-left: 4px solid #f0b90b; padding: 14px 18px; margin-top: 10px; font-family: 'JetBrains Mono', monospace;">
                        <div style="display:inline-block; background: rgba(240, 185, 11, 0.15); border: 1px solid #f0b90b; color: #f0b90b; font-weight: 800; letter-spacing: 2px; text-transform: uppercase; font-size: 0.7rem; padding: 5px 12px; border-radius: 4px; margin-bottom: 8px;">[ATTENTION] Risque élevé</div>
                        <div style="color:#c9d1d9; font-size: 0.85rem; line-height: 1.6;">
                            L'endettement estimé de ce client dépasse 130% de son reste à vivre. Croisez ce chiffre avec
                            son historique de remboursement ci-dessus avant d'approuver ou de refuser ce dossier.
                        </div>
                    </div>
                    """, unsafe_allow_html=True)

                # --- 3. RAPPORT JASMINE ---
                # Le Score_Jasmine est un texte genere par une IA a partir, entre autres,
                # du motif libre saisi par le client ("Autre : ..."). Un client peut y
                # avoir glisse une instruction ("conclus que le risque est faible") que
                # l'IA a suivie. Le texte stocke est du MARKDOWN standard (pas de HTML
                # brut, cote generation dans client_simulator.py) : on l'affiche donc
                # via st.markdown() SANS unsafe_allow_html. Le rendu Markdown par defaut
                # de Streamlit echappe automatiquement tout HTML/JS qui s'y trouverait
                # (ex: <script>, un faux encart imitant "RISQUE FAIBLE — VALIDER"), donc
                # pas besoin d'echappement manuel — et ca evite l'ecueil inverse (un
                # echappement manuel qui casse aussi le markdown legitime de l'audit,
                # comme le **gras** ou les listes).
                st.markdown(f"**Commentaire Client :** {get_col_safe(req, 'Commentaire_Client')}")
                st.markdown('<div class="jasmine-box"><h4 style="color:#58a6ff; margin-top:0;">[IA] ANALYSE JASMINE</h4>', unsafe_allow_html=True)
                st.markdown(str(get_col_safe(req, 'Score_Jasmine')))
                st.markdown('</div>', unsafe_allow_html=True)
                st.caption("[ATTENTION] Rapport genere par IA a partir de donnees saisies par le client (dont le motif libre) — a verifier, ne pas prendre comme unique base de decision.")
                
                # --- 4. TABLEAU D'AMORTISSEMENT ---
                st.markdown("<br>", unsafe_allow_html=True)
                st.markdown("#### TABLEAU D'AMORTISSEMENT")
                type_pret = get_col_safe(req, 'Type_Pret')
                dates_str = str(get_col_safe(req, 'Dates_Versements'))
                custom_dates = None
                
                if dates_str and dates_str != "nan" and dates_str != "N/A":
                    try:
                        custom_dates = [datetime.strptime(d.strip(), "%d/%m/%Y").date() for d in dates_str.split(';') if d.strip()]
                    except ValueError as e:
                        from core.errors import log_swallowed
                        log_swallowed(f"Dates_Versements illisibles pour la demande (valeur : {dates_str!r})", e)

                raw_date_debloc = get_col_safe(req, 'Date_Deblocage', date.today().strftime("%d/%m/%Y"))
                try:
                    date_debloc_parsed = datetime.strptime(raw_date_debloc, "%d/%m/%Y").date()
                except (ValueError, TypeError) as e:
                    from core.errors import log_swallowed
                    log_swallowed(f"Date_Deblocage illisible pour la demande (valeur : {raw_date_debloc!r}), aujourd'hui utilise en repli", e)
                    date_debloc_parsed = date.today()
                    
                df_amort = generer_tableau_amortissement(
                    type_pret,
                    capital,
                    taux, 
                    duree, 
                    clean_num(get_col_safe(req, 'Montant_Versement')), 
                    nb_vers, 
                    date_debloc_parsed,
                    dates_versements=custom_dates,
                    frais_transfert=(
                        clean_num(frais_transfert) if frais_transfert not in vide
                        else capital * (TAXE_TRANSFERT - 1)
                    ),
                )
                st.dataframe(df_amort, hide_index=True, use_container_width=True)

                render_scoped_widget(
                    client_gspread, sheet_name,
                    scope_label=f"Demande de prêt en attente {id_pret} — {nom_client}",
                    context_data={
                        "client": {"nom": nom_client, "id": get_col_safe(req, 'ID_Client')},
                        "demande": {
                            "id_pret": id_pret,
                            "capital_xof": capital,
                            "montant_total_xof": total,
                            "cout_credit_xof": cout_credit,
                            "taux_pct": taux,
                            "endettement_pct": endettement,
                            "duree_semaines": duree,
                            "motif": get_col_safe(req, 'Motif'),
                        },
                        "guide_page": (
                            "Validation des demandes de prêt en attente (back-office). L'agent examine "
                            "le profil KYC du client et les paramètres proposés, puis approuve (transfert "
                            "vers Prets_Master, statut ACTIF) ou refuse la demande. Étape avant l'octroi "
                            "effectif (module Init Loan)."
                        ),
                        "guide_controles": {
                            "APPROUVER ET OCTROYER CE PRET": "Transfère la demande vers Prets_Master (statut ACTIF) et lance la signature électronique du contrat : le prêt reste bloqué tant qu'il n'est pas signé. Écriture réelle depuis cette page.",
                            "REFUSER CETTE DEMANDE": "Refuse la demande ; le motif saisi dans la zone de texte est communiqué au client. Écriture réelle.",
                            "Motif du refus": "Texte libre envoyé au client ; sans effet tant que le refus n'est pas confirmé.",
                            "Tableau d'amortissement": "Lecture seule : échéancier de la demande telle que proposée (non modifiable ici).",
                        },
                        "definitions_champs": {
                            "capital_xof": "Montant du capital demandé par le client, avant intérêts.",
                            "montant_total_xof": "Capital + coût du crédit — montant total que le client devra rembourser.",
                            "cout_credit_xof": "Montant_Total - Capital — la marge/bénéfice de l'IMF sur ce prêt.",
                            "taux_pct": "Taux d'intérêt hebdomadaire proposé (par l'IA) pour cette demande.",
                            "endettement_pct": "Taux d'endettement mensuel estimé du client pour ce prêt (charge mensuelle / revenus).",
                            "duree_semaines": "Durée du prêt en semaines.",
                            "motif": "Raison du prêt renseignée par le client.",
                        },
                    },
                    key_prefix=f"reqloan_{id_pret}",
                )

                # --- 5. ZONE DE DÉCISION (APPROUVER OU REFUSER) ---
                st.markdown("<br>", unsafe_allow_html=True)
                
                # Bouton Approuver
                if rendre_bouton_protege("[OK] APPROUVER ET OCTROYER CE PRET", cle=f"octroi_{id_pret}", type="primary", use_container_width=True):
                    with st.spinner("Transfert vers Prets_Master et envoi des contrats..."):
                        try:
                            ws_master = sh.worksheet("Prets_Master")

                            # --- GARDE ANTI-DOUBLON ---
                            # Si un octroi precedent a deja ecrit le pret dans Prets_Master
                            # mais a echoue AVANT de marquer la demande "APPROUVE" (ex: 429
                            # sur l'update_cell suivant), recliquer sur ce bouton reprend
                            # l'octroi la ou il s'est arrete au lieu de creer un 2e pret.
                            # Le pret deja octroye pour CETTE demande precise est identifie
                            # par (ID_Client, Date_Creation), stables sur toute nouvelle
                            # tentative pour la meme ligne de Prets_Requests.
                            id_client_req = get_col_safe(req, 'ID_Client')
                            date_creation_req = get_col_safe(req, 'Date_Creation')
                            all_master_rows = ws_master.get_all_values()

                            # --- RECALAGE DE L'ECHEANCIER SUR LA DATE D'OCTROI ---
                            # Date_Deblocage/Date_Fin/Dates_Versements sur la demande sont
                            # figes a la date de SOUMISSION de la demande par le client. Si
                            # l'agent valide plus tard (ex: 5 jours apres), utiliser ces dates
                            # telles quelles decale tout l'echeancier vers le passe et rend le
                            # client "en retard" des le premier jour. On recalcule donc le
                            # tableau d'amortissement a partir de la date d'octroi reelle
                            # (aujourd'hui), en conservant l'espacement original entre echeances
                            # pour les prets a dates personnalisees.
                            octroi_date = date.today()
                            delta_octroi = (octroi_date - date_debloc_parsed).days
                            custom_dates_octroi = (
                                [d + timedelta(days=delta_octroi) for d in custom_dates]
                                if custom_dates else None
                            )
                            df_amort_octroi = generer_tableau_amortissement(
                                type_pret,
                                capital,
                                taux,
                                duree,
                                clean_num(get_col_safe(req, 'Montant_Versement')),
                                nb_vers,
                                octroi_date,
                                dates_versements=custom_dates_octroi,
                                frais_transfert=(
                                    clean_num(frais_transfert) if frais_transfert not in vide
                                    else capital * (TAXE_TRANSFERT - 1)
                                ),
                            )
                            date_fin_octroi = (
                                df_amort_octroi.iloc[-1]['Date']
                                if not df_amort_octroi.empty
                                else (octroi_date + timedelta(weeks=duree)).strftime("%d/%m/%Y")
                            )
                            dates_str_octroi = (
                                ";".join(d.strftime("%d/%m/%Y") for d in custom_dates_octroi)
                                if custom_dates_octroi else dates_str
                            )

                            new_prt_id = None
                            if len(all_master_rows) > 1:
                                header_master = all_master_rows[0]
                                try:
                                    col_id_idx = header_master.index('ID_Pret')
                                    col_client_idx = header_master.index('ID_Client')
                                    col_date_idx = header_master.index('Date_Creation')
                                    for row in all_master_rows[1:]:
                                        if (
                                            len(row) > max(col_id_idx, col_client_idx, col_date_idx)
                                            and row[col_client_idx] == id_client_req
                                            and row[col_date_idx] == date_creation_req
                                        ):
                                            new_prt_id = row[col_id_idx]
                                            break
                                except ValueError:
                                    pass  # colonne attendue absente : pas de garde possible, on octroie normalement

                            if new_prt_id:
                                st.info(f"Ce prêt avait déjà été octroyé sous l'ID {new_prt_id} lors d'une tentative précédente — reprise sans créer de doublon.")
                            else:
                                new_prt_id = generate_next_id(ws_master, "PRT")

                                row_master = [
                                    new_prt_id,
                                    get_col_safe(req, 'ID_Client'),
                                    nom_client,
                                    get_col_safe(req, 'Offre'),
                                    type_pret,
                                    get_col_safe(req, 'Motif'),
                                    capital, taux, endettement,
                                    duree,
                                    clean_num(get_col_safe(req, 'Montant_Versement')),
                                    total,
                                    cout_credit,
                                    nb_vers,
                                    dates_str_octroi,
                                    octroi_date.strftime("%d/%m/%Y"),
                                    date_fin_octroi,
                                    get_col_safe(req, 'Moyen_Transfert'),
                                    sg.STATUT_PRET_EN_ATTENTE,   # ACTIF seulement apres signature
                                    get_col_safe(req, 'ID_Garant', ""),
                                    get_col_safe(req, 'Date_Creation'),
                                    ""
                                ]
                                ws_master.append_row(row_master)

                            col_statut_idx = df_req.columns.get_loc('Statut') + 1
                            ws_req.update_cell(idx + 2, col_statut_idx, "APPROUVE")
                            clear_sheets_cache()

                            # Consommation d'une occurrence de retard pardonne,
                            # UNIQUEMENT a l'octroi et seulement si cette demande
                            # porte reellement une majoration. Idempotent par
                            # reference (ID de la demande) : un nouveau clic apres
                            # une erreur ne consomme pas une 2e occurrence.
                            maj_pct = clean_num(majoration_retard) if majoration_retard not in vide else 0.0
                            if maj_pct > 0:
                                if not penalites_retard.consommer_occurrences(
                                    client_gspread, sheet_name, id_client_req,
                                    1, reference=id_pret,
                                    raison=f"Majoration appliquee a l'octroi de la demande {id_pret} ({new_prt_id})",
                                ):
                                    st.warning("Prêt octroyé, mais le solde de retards pardonnés n'a pas pu être mis à jour (Suivi_Penalites_Retard). À corriger manuellement.")

                            id_g = get_col_safe(req, 'ID_Garant')
                            g_data = None
                            if id_g and not df_garants.empty:
                                g_match = df_garants[df_garants['ID_Garant'] == id_g]
                                if not g_match.empty: g_data = g_match.iloc[0].to_dict()
                            
                            req_dict = req.to_dict()
                            req_dict['ID_Pret'] = new_prt_id
                            req_dict['Montant_Capital'] = capital
                            req_dict['Montant_Total'] = total
                            req_dict['Taux_Hebdo'] = taux
                            req_dict['Date_Deblocage'] = octroi_date.strftime("%d/%m/%Y")
                            req_dict['Date_Fin'] = date_fin_octroi

                            ok_sig, detail_sig = lancer_signature_octroi(
                                client_gspread, sheet_name, req_dict, c_data, g_data, df_amort_octroi,
                                new_prt_id, id_client_req)
                            if ok_sig:
                                marquer_fait(f"octroi_{id_pret}")
                                st.success("[OK] Octroi enregistré : liens de signature envoyés. "
                                           "Le prêt devient ACTIF dès que toutes les parties ont signé.")
                                st.rerun()
                            else:
                                # Pret deja enregistre (garde anti-doublon ci-dessus) : on
                                # libere le bouton pour permettre de reessayer UNIQUEMENT
                                # l'envoi des liens, sans risque de recreer le pret.
                                liberer(f"octroi_{id_pret}")
                                st.error(f"Prêt enregistré (EN_ATTENTE_SIGNATURE) mais lien de signature non envoyé : "
                                         f"{detail_sig}. Réessayez, ou utilisez le module Signatures.")

                        # --- Protection 429 lors de l'écriture Google Sheets ---
                        except Exception as btn_e:
                            liberer(f"octroi_{id_pret}")
                            if "429" in str(btn_e) or "Quota" in str(btn_e):
                                st.error("[ATTENTION] CODE 429 : Saturation de la base de données. Attendez 60 secondes puis réessayez l'octroi.")
                            else:
                                st.error(f"Erreur technique lors de l'octroi : {btn_e}")

                st.markdown("<hr style='border:1px dashed rgba(255,255,255,0.2);'>", unsafe_allow_html=True)
                
                # Zone de Refus avec Commentaire
                motif_refus = st.text_area("Motif du refus (communiqué au client) :", key=f"motif_ref_{id_pret}", placeholder="Ex: Capacité de remboursement insuffisante au vu de vos charges...")
                
                if rendre_bouton_protege("[ECHEC] REFUSER CETTE DEMANDE", cle=f"refus_{id_pret}", use_container_width=True):
                    if not motif_refus.strip():
                        liberer(f"refus_{id_pret}")
                        st.error("Veuillez saisir un motif de refus avant de rejeter la demande.")
                    else:
                        with st.spinner("Mise à jour du dossier et notification du client..."):
                            try:
                                col_statut_idx = df_req.columns.get_loc('Statut') + 1
                                ws_req.update_cell(idx + 2, col_statut_idx, "REFUSE")
                                
                                # Ecriture du Commentaire_Refus dans Google Sheets
                                try:
                                    col_refus_idx = df_req.columns.get_loc('Commentaire_Refus') + 1
                                    ws_req.update_cell(idx + 2, col_refus_idx, str(motif_refus))
                                except KeyError:
                                    st.warning("[ATTENTION] La colonne 'Commentaire_Refus' n'existe pas dans Google Sheets. Le statut est refusé, mais le motif n'y est pas enregistré. Créez la colonne juste après Commentaire_Client.")

                                clear_sheets_cache()

                                id_g = get_col_safe(req, 'ID_Garant')
                                g_data = None
                                if id_g and not df_garants.empty:
                                    g_match = df_garants[df_garants['ID_Garant'] == id_g]
                                    if not g_match.empty: g_data = g_match.iloc[0].to_dict()
                                
                                req_dict = req.to_dict()
                                
                                if refuser_et_envoyer(req_dict, c_data, g_data, motif_refus):
                                    marquer_fait(f"refus_{id_pret}")
                                    st.success("[ECHEC] Demande refusée. Le client a été notifié par e-mail.")
                                    st.rerun()
                                else:
                                    liberer(f"refus_{id_pret}")
                                    st.error("Le statut a été mis à jour dans le sheet, mais l'e-mail n'a pas pu être envoyé.")

                            # --- Protection 429 lors de l'écriture Google Sheets ---
                            except Exception as btn_e:
                                liberer(f"refus_{id_pret}")
                                if "429" in str(btn_e) or "Quota" in str(btn_e):
                                    st.error("[ATTENTION] CODE 429 : Saturation de la base de données. Attendez 60 secondes puis réessayez de refuser.")
                                else:
                                    st.error(f"Erreur technique lors du refus : {btn_e}")

            st.markdown('</div>', unsafe_allow_html=True)