ZakyF commited on
Commit
5b1f45d
·
1 Parent(s): 77b155b

feat: update UI styling, refine risk scoring logic, and enhance NBO recommendations.

Browse files
Files changed (1) hide show
  1. app.py +88 -74
app.py CHANGED
@@ -9,26 +9,39 @@ from google import genai
9
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
10
  client_ai = genai.Client(api_key=GOOGLE_API_KEY) if GOOGLE_API_KEY else None
11
 
12
- # --- UI STYLE: BANK NAGARI PREMIUM (BLUE-GOLD) ---
 
13
  custom_css = """
14
  @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700;800&display=swap');
15
- body, .gradio-container { font-family: 'Plus Jakarta Sans', sans-serif !important; background-color: #f8fafc !important; }
16
- .nagari-header { background: linear-gradient(135deg, #0514DE 0%, #82C3EB 100%); padding: 35px; border-radius: 15px; border-bottom: 6px solid #F7BD87; margin-bottom: 25px; text-align: center; }
 
 
 
 
 
17
  .nagari-header h1 { color: #FFFFFF !important; font-weight: 800 !important; margin: 0; font-size: 2.2em; }
18
- .card-sidebar { background: #E0EDF4; border-radius: 15px; padding: 25px; border: 1.5px solid #82C3EB; box-shadow: 0 4px 12px rgba(5, 20, 222, 0.1); }
19
- .health-badge { background: white; padding: 12px; border-radius: 8px; margin-bottom: 12px; border-left: 5px solid #0514DE; font-size: 0.95em; }
20
- .report-card { background: white; border-radius: 12px; padding: 30px; border: 1px solid #E2E8F0; line-height: 1.8; color: #1e293b; }
 
 
 
 
 
 
 
 
21
  .nbo-box { background: #fffdf0; border: 2px solid #F7BD87; padding: 20px; border-radius: 10px; margin-top: 20px; }
22
- .viz-explanation { background: #f1f5f9; padding: 15px; border-radius: 8px; margin-top: 10px; font-size: 0.9em; border-left: 4px solid #82C3EB; }
23
  """
24
 
25
- class ArchonPrecisionEngine:
26
  def __init__(self):
27
  self.load_data()
28
 
29
  def load_data(self):
30
- # FASE 1: DATA FOUNDATION [cite: 1, 3]
31
  try:
 
32
  self.df_txn = pd.read_csv('transactions.csv', parse_dates=['date']).sort_values('date')
33
  self.df_cust = pd.read_csv('customers.csv')
34
  self.df_bal = pd.read_csv('balances_revised.csv', parse_dates=['month']).sort_values('month')
@@ -37,6 +50,8 @@ class ArchonPrecisionEngine:
37
 
38
  def analyze(self, customer_id):
39
  cid = str(customer_id).strip().upper()
 
 
40
  u_txn = self.df_txn[self.df_txn['customer_id'] == cid].copy()
41
  u_bal = self.df_bal[self.df_bal['customer_id'] == cid].sort_values('month')
42
  u_rep = self.df_rep[self.df_rep['customer_id'] == cid]
@@ -45,119 +60,120 @@ class ArchonPrecisionEngine:
45
  if u_txn.empty or u_info_df.empty: return None
46
  u_info = u_info_df.iloc[0]
47
 
48
- # --- FASE 2: TRANSACTION INTELLIGENCE (Refined Keywords) ---
49
- essential_list = {'groceries', 'utilities', 'transport', 'healthcare', 'education', 'bill', 'loan', 'salary'}
 
50
 
51
  def classify_exp(row):
52
- # Cek merchant_category, purpose_code, dan deskripsi secara case-insensitive [cite: 20, 21, 23]
53
  cat = str(row.get('merchant_category', '')).lower()
54
  purp = str(row.get('purpose_code', '')).lower()
55
- desc = str(row.get('raw_description', '')).lower()
56
-
57
- if any(k in cat for k in essential_list) or any(k in purp for k in essential_list) or any(k in desc for k in essential_list):
58
- return 'essential'
59
  return 'discretionary'
60
 
61
  u_txn['expense_type'] = u_txn.apply(classify_exp, axis=1)
62
 
63
- # --- FASE 3 & 4: RISK SCORING (STRICT 30/20/20/20/10) [cite: 141-145] ---
64
  inc_txn = u_txn[u_txn['transaction_type'] == 'credit']['amount'].sum()
65
  ref_inc = max(inc_txn, u_info['monthly_income'])
66
  exp_total = u_txn[u_txn['transaction_type'] == 'debit']['amount'].sum()
67
- er = min(exp_total / ref_inc, 1.0) if ref_inc > 0 else 1.0
68
 
69
- # Normalisasi Skor [cite: 132]
70
  er_s = 1.0 if er > 0.8 else (0.5 if er > 0.5 else 0.0)
71
  bt_s = 1.0 if len(u_bal) >= 2 and u_bal.iloc[-1]['avg_balance'] < u_bal.iloc[-2]['avg_balance'] else 0.0
72
  od_s = 1.0 if (u_bal['min_balance'] <= 0).any() else 0.0
73
  mp_s = 1.0 if (u_rep['status'] == 'late').any() else 0.0
 
74
 
75
- # Rumus Final Risk Score [cite: 147]
76
- score = (0.3 * er_s) + (0.2 * bt_s) + (0.2 * od_s) + (0.2 * mp_s) + 0.1
77
- risk_lv = "HIGH" if score >= 0.70 else ("MEDIUM" if score >= 0.40 else "LOW")
78
-
79
- # --- FASE 5: NBO ENGINE (Actionable Detail) [cite: 178] ---
80
- if risk_lv == "HIGH" or mp_s == 1:
81
- action, desc = "Restructuring Suggestion", "Fokus penyelamatan kredit untuk menghindari gagal bayar."
82
- steps = ["Penjadwalan ulang tenor", "Konsolidasi kewajiban", "Review bunga."] [cite: 220]
83
- elif er > 0.6:
84
- action, desc = "Spending Control", "Pengendalian langsung pada pos belanja non-esensial."
85
- steps = ["Setting limit QRIS Nagari", "Blokir sementara merchant gaya hidup", "Budgeting manual."] [cite: 207]
 
 
 
86
  elif risk_lv == "LOW":
87
- action, desc = "Promote Investment", "Optimalisasi dana surplus ke produk aset produktif."
88
- steps = ["Tabungan Berjangka", "Deposito Gold", "Investasi Reksa Dana."] [cite: 194]
89
  else:
90
- action, desc = "Financial Education", "Edukasi berkala untuk stabilitas jangka panjang."
91
- steps = ["Modul cerdas belanja", "Webinar Perencanaan", "Tips Cashflow."] [cite: 214]
92
 
93
- return risk_lv, score, er, u_bal, u_txn, exp_total, ref_inc, action, desc, steps, er_s, bt_s, od_s, mp_s
94
 
95
- def build_report(self, risk_lv, score, er, u_bal, exp, inc, action, desc, steps, cid, u_txn):
96
- # FASE 6: INSIGHT VISUALIZATION [cite: 280]
97
- txt = f"### ANALISIS EKSEKUTIF: {risk_lv} RISK\n\n"
98
- txt += f"**Identitas**: {cid} | **Skor Risiko**: {score:.2f}\n\n"
 
99
 
100
- txt += f"**1. Mengapa Level Ini?**\n"
101
- txt += f"* **Rasio Pengeluaran ({er:.1%})**: Anda menghabiskan Rp{exp:,.0f} dari pendapatan Rp{inc:,.0f}. "
102
- txt += "Rasio di atas 80% sangat kritis." if er > 0.8 else "Angka ini menunjukkan manajemen yang sehat."
103
 
104
  if not u_bal.empty:
105
- txt += f"\n* **Analisis Saldo**: Saldo saat ini Rp{u_bal.iloc[-1]['avg_balance']:,.0f}. "
106
- txt += "Tren saldo sedang menurun (Drawdown)." if len(u_bal) > 1 and u_bal.iloc[-1]['avg_balance'] < u_bal.iloc[-2]['avg_balance'] else "Pertumbuhan saldo positif."
107
 
108
  txt += f"\n\n<div class='nbo-box'>REKOMENDASI TINDAKAN (NBO): {action}\n\n"
109
- txt += f"**Langkah Implementasi:**\n"
110
- for i, s in enumerate(steps, 1): txt += f"{i}. {s}\n"
111
- txt += f"</div>"
112
  return txt
113
 
114
- def create_viz(self, u_bal, u_txn):
115
- # 1. Cashflow
116
  u_txn['m'] = u_txn['date'].dt.to_period('M').dt.to_timestamp()
117
  cf = u_txn.groupby(['m', 'transaction_type'])['amount'].sum().unstack().fillna(0)
118
  f1 = go.Figure()
119
- f1.add_trace(go.Bar(x=cf.index, y=cf.get('credit', 0), name='Pemasukan (Light Blue)', marker_color='#82C3EB'))
120
- f1.add_trace(go.Bar(x=cf.index, y=cf.get('debit', 0), name='Pengeluaran (Deep Blue)', marker_color='#0514DE'))
121
- f1.update_layout(title="Arus Kas Bulanan", barmode='group', template='plotly_white')
122
 
123
- # 2. Tren Saldo
124
  f2 = go.Figure()
125
- f2.add_trace(go.Scatter(x=u_bal['month'], y=u_bal['avg_balance'], name='Rata-rata Saldo', line=dict(color='#F7BD87', width=4)))
126
  f2.update_layout(title="Kesehatan Pertumbuhan Saldo", template='plotly_white')
127
 
128
- # 3. FIXED Pie Chart: Explicit Color Mapping
129
  exp_dist = u_txn[u_txn['transaction_type'] == 'debit'].groupby('expense_type')['amount'].sum().reset_index()
130
- color_map = {'essential': '#0514DE', 'discretionary': '#F7BD87'} # Biru=Essential, Emas=Disc
131
-
132
  f3 = go.Figure(data=[go.Pie(
133
  labels=exp_dist['expense_type'],
134
  values=exp_dist['amount'],
135
  hole=.4,
136
- marker=dict(colors=[color_map[x] for x in exp_dist['expense_type']])
137
  )])
138
- f3.update_layout(title="Komposisi Gaya Hidup vs Kebutuhan")
139
 
140
  return f1, f2, f3
141
 
142
  # --- UI LOGIC ---
143
- engine = ArchonPrecisionEngine()
144
 
145
  def run_app(cust_id):
146
  res = engine.analyze(cust_id)
147
- if not res: return "❌ ID Tidak Valid", "Gunakan C0001 - C0120", None, None, None
148
 
149
- risk_lv, score, er, u_bal, u_txn, exp, inc, action, desc, steps, er_s, bt_s, od_s, mp_s = res
150
- report = engine.build_report(risk_lv, score, er, u_bal, exp, inc, action, desc, steps, cust_id, u_txn)
151
- p1, p2, p3 = engine.create_viz(u_bal, u_txn)
152
 
153
  color = "#ef4444" if risk_lv == "HIGH" else ("#f59e0b" if risk_lv == "MEDIUM" else "#10b981")
154
  sidebar = f"""
155
- <div class='card-sidebar'>
156
- <h2 style='color: #0514DE; margin:0;'>Archon Quick Look</h2>
157
- <div style='background:{color}; color:white; padding:10px 20px; border-radius:30px; font-weight:bold; display:inline-block; margin:15px 0;'>{risk_lv} RISK</div>
158
- <div class='health-badge'><b>Score:</b> {score:.2f} / 1.00</div>
159
- <div class='health-badge'><b>ER:</b> {er:.1%} {'⚠️' if er > 0.8 else '✔️'}</div>
160
- <div class='health-badge'><b>Trend:</b> {'🔻 Turun' if bt_s == 1 else '🔺 Stabil'}</div>
161
  </div>
162
  """
163
  return sidebar, report, p1, p2, p3
@@ -176,15 +192,13 @@ with gr.Blocks(css=custom_css) as demo:
176
  with gr.Tab("Visual Analytics"):
177
  gr.Markdown("### 1. Inflow vs Outflow")
178
  plot_cf = gr.Plot()
179
- gr.HTML("<div class='viz-explanation'><b>Arus Kas:</b> Batang muda (Pemasukan) harus lebih tinggi dari batang tua (Pengeluaran).</div>")
180
  gr.Markdown("---")
181
- gr.Markdown("### 2. Tren Saldo")
182
  plot_bal = gr.Plot()
183
- gr.HTML("<div class='viz-explanation'><b>Resiliensi:</b> Garis naik menunjukkan nasabah kuat menghadapi krisis.</div>")
184
  gr.Markdown("---")
185
  gr.Markdown("### 3. Gaya Hidup (Essential vs Discretionary)")
186
  plot_dist = gr.Plot()
187
- gr.HTML("<div class='viz-explanation'><b>Warna:</b> Biru = Kebutuhan Pokok (Essential). Emas = Gaya Hidup (Discretionary).</div>")
188
 
189
  btn.click(fn=run_app, inputs=id_in, outputs=[out_side, out_report, plot_cf, plot_bal, plot_dist])
190
 
 
9
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
10
  client_ai = genai.Client(api_key=GOOGLE_API_KEY) if GOOGLE_API_KEY else None
11
 
12
+ # --- PALETTE BANK NAGARI (BERDASARKAN NAGARI.PNG) ---
13
+ # Primary: #05A4DE, Light: #82C3EB, Pale: #E0EDF4, White: #FFFFFF, Gold: #F7BD87
14
  custom_css = """
15
  @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700;800&display=swap');
16
+ body, .gradio-container { font-family: 'Plus Jakarta Sans', sans-serif !important; background-color: #FFFFFF !important; }
17
+
18
+ .nagari-header {
19
+ background: linear-gradient(135deg, #05A4DE 0%, #82C3EB 100%);
20
+ padding: 35px; border-radius: 15px; border-bottom: 6px solid #F7BD87;
21
+ margin-bottom: 25px; text-align: center;
22
+ }
23
  .nagari-header h1 { color: #FFFFFF !important; font-weight: 800 !important; margin: 0; font-size: 2.2em; }
24
+
25
+ .sidebar-nagari {
26
+ background: #E0EDF4; border-radius: 15px; padding: 25px;
27
+ border: 1.5px solid #82C3EB; box-shadow: 0 4px 12px rgba(5, 164, 222, 0.1);
28
+ }
29
+ .health-badge { background: white; padding: 12px; border-radius: 8px; margin-bottom: 12px; border-left: 5px solid #05A4DE; font-size: 0.95em; }
30
+
31
+ .report-card {
32
+ background: white; border-radius: 12px; padding: 30px;
33
+ border: 1px solid #E2E8F0; line-height: 1.8; color: #1e293b;
34
+ }
35
  .nbo-box { background: #fffdf0; border: 2px solid #F7BD87; padding: 20px; border-radius: 10px; margin-top: 20px; }
 
36
  """
37
 
38
+ class ArchonPlatinumEngine:
39
  def __init__(self):
40
  self.load_data()
41
 
42
  def load_data(self):
 
43
  try:
44
+ # FASE 1: DATA FOUNDATION [cite: 1-6]
45
  self.df_txn = pd.read_csv('transactions.csv', parse_dates=['date']).sort_values('date')
46
  self.df_cust = pd.read_csv('customers.csv')
47
  self.df_bal = pd.read_csv('balances_revised.csv', parse_dates=['month']).sort_values('month')
 
50
 
51
  def analyze(self, customer_id):
52
  cid = str(customer_id).strip().upper()
53
+
54
+ # Validasi Data Exist
55
  u_txn = self.df_txn[self.df_txn['customer_id'] == cid].copy()
56
  u_bal = self.df_bal[self.df_bal['customer_id'] == cid].sort_values('month')
57
  u_rep = self.df_rep[self.df_rep['customer_id'] == cid]
 
60
  if u_txn.empty or u_info_df.empty: return None
61
  u_info = u_info_df.iloc[0]
62
 
63
+ # --- FASE 2: TRANSACTION INTELLIGENCE ---
64
+ essential_cats = {'groceries', 'utilities', 'transport', 'healthcare', 'education'}
65
+ essential_purps = {'bill_payment', 'salary_deduction', 'loan_repayment'}
66
 
67
  def classify_exp(row):
 
68
  cat = str(row.get('merchant_category', '')).lower()
69
  purp = str(row.get('purpose_code', '')).lower()
70
+ if cat in essential_cats or purp in essential_purps: return 'essential'
 
 
 
71
  return 'discretionary'
72
 
73
  u_txn['expense_type'] = u_txn.apply(classify_exp, axis=1)
74
 
75
+ # --- FASE 3 & 4: RISK SCORING (WEIGHTS 30/20/20/20/10) [cite: 141-145] ---
76
  inc_txn = u_txn[u_txn['transaction_type'] == 'credit']['amount'].sum()
77
  ref_inc = max(inc_txn, u_info['monthly_income'])
78
  exp_total = u_txn[u_txn['transaction_type'] == 'debit']['amount'].sum()
79
+ er = exp_total / ref_inc if ref_inc > 0 else 1.0
80
 
81
+ # Scoring Logic [cite: 136-138]
82
  er_s = 1.0 if er > 0.8 else (0.5 if er > 0.5 else 0.0)
83
  bt_s = 1.0 if len(u_bal) >= 2 and u_bal.iloc[-1]['avg_balance'] < u_bal.iloc[-2]['avg_balance'] else 0.0
84
  od_s = 1.0 if (u_bal['min_balance'] <= 0).any() else 0.0
85
  mp_s = 1.0 if (u_rep['status'] == 'late').any() else 0.0
86
+ vol_s = 0.5 # Default stability
87
 
88
+ # Final Score [cite: 147]
89
+ score = (0.3 * er_s) + (0.2 * bt_s) + (0.2 * od_s) + (0.2 * mp_s) + (0.1 * vol_s)
90
+ risk_lv = "HIGH" if score >= 0.7 else ("MEDIUM" if score >= 0.4 else "LOW")
91
+
92
+ # --- FASE 5: NBO ENGINE (RULE BASED) [cite: 178-220] ---
93
+ disc_spending = u_txn[u_txn['expense_type'] == 'discretionary']['amount'].sum()
94
+ disc_ratio = disc_spending / exp_total if exp_total > 0 else 0
95
+
96
+ if risk_lv == "HIGH":
97
+ action = "Restructuring Suggestion"
98
+ desc = "Nasabah berada dalam risiko gagal bayar tinggi. Bank perlu menawarkan penjadwalan ulang tenor atau penurunan suku bunga sementara."
99
+ elif er > 0.6 or disc_ratio > 0.6:
100
+ action = "Spending Control"
101
+ desc = "Terdeteksi pola belanja gaya hidup yang berlebihan. Rekomendasikan pembatasan limit harian atau edukasi budgeting."
102
  elif risk_lv == "LOW":
103
+ action = "Promote Investment"
104
+ desc = "Kapasitas finansial nasabah sangat baik. Waktunya menawarkan produk deposito atau reksa dana Nagari."
105
  else:
106
+ action = "Financial Education"
107
+ desc = "Berikan konten literasi keuangan untuk menjaga stabilitas arus kas nasabah."
108
 
109
+ return risk_lv, score, er, u_bal, u_txn, exp_total, ref_inc, action, desc, bt_s, od_s, mp_s
110
 
111
+ def build_narrative(self, risk_lv, score, er, u_bal, exp, inc, action, desc, cid, u_txn):
112
+ # FASE 6: EXPLAINABLE SUMMARY [cite: 294-297]
113
+ txt = f"### LAPORAN ANALISIS EKSEKUTIF: {risk_lv} RISK\n\n"
114
+ txt = f"**Interpretasi Skor:**\nNasabah **{cid}** memiliki skor risiko **{score:.2f}**. "
115
+ txt += f"Artinya, dari kacamata bank, nasabah ini memiliki tingkat resiliensi **{risk_lv.lower()}**. "
116
 
117
+ txt += f"\n\n**Analisis Data Riil:**\n"
118
+ txt += f"* **Expense Ratio ({er:.1%})**: Nasabah menghabiskan Rp{exp:,.0f} dari pendapatan Rp{inc:,.0f}. "
119
+ txt += "Angka ini menunjukkan pengeluaran yang melebihi pendapatan (defisit)." if er > 1 else "Manajemen belanja masih terkendali."
120
 
121
  if not u_bal.empty:
122
+ txt += f"\n* **Analisis Saldo**: Saldo rata-rata terakhir Rp{u_bal.iloc[-1]['avg_balance']:,.0f}. "
123
+ txt += "Tren saldo sedang menurun, waspadai penarikan dana besar secara konstan." if len(u_bal) > 1 and u_bal.iloc[-1]['avg_balance'] < u_bal.iloc[-2]['avg_balance'] else "Pertumbuhan saldo terpantau stabil."
124
 
125
  txt += f"\n\n<div class='nbo-box'>REKOMENDASI TINDAKAN (NBO): {action}\n\n"
126
+ txt += f"**Maksud & Tujuan:** {desc}\n\n"
127
+ txt += f"**Langkah Implementasi:** Hubungi nasabah via aplikasi Nagari Mobile untuk menawarkan program asistensi keuangan atau penawaran produk relevan.</div>"
 
128
  return txt
129
 
130
+ def create_plots(self, u_bal, u_txn):
131
+ # 1. Cashflow (Inflow vs Outflow)
132
  u_txn['m'] = u_txn['date'].dt.to_period('M').dt.to_timestamp()
133
  cf = u_txn.groupby(['m', 'transaction_type'])['amount'].sum().unstack().fillna(0)
134
  f1 = go.Figure()
135
+ f1.add_trace(go.Bar(x=cf.index, y=cf.get('credit', 0), name='Pemasukan (Inflow)', marker_color='#82C3EB'))
136
+ f1.add_trace(go.Bar(x=cf.index, y=cf.get('debit', 0), name='Pengeluaran (Outflow)', marker_color='#05A4DE'))
137
+ f1.update_layout(title="Laporan Arus Kas Bulanan", barmode='group', template='plotly_white')
138
 
139
+ # 2. Tren Saldo [cite: 301]
140
  f2 = go.Figure()
141
+ f2.add_trace(go.Scatter(x=u_bal['month'], y=u_bal['avg_balance'], name='Saldo Rata-rata', line=dict(color='#F7BD87', width=4)))
142
  f2.update_layout(title="Kesehatan Pertumbuhan Saldo", template='plotly_white')
143
 
144
+ # 3. FIXED Pie Chart
145
  exp_dist = u_txn[u_txn['transaction_type'] == 'debit'].groupby('expense_type')['amount'].sum().reset_index()
146
+ # Mapping Warna: Blue=Essential, Gold=Discretionary
147
+ color_map = {'essential': '#05A4DE', 'discretionary': '#F7BD87'}
148
  f3 = go.Figure(data=[go.Pie(
149
  labels=exp_dist['expense_type'],
150
  values=exp_dist['amount'],
151
  hole=.4,
152
+ marker=dict(colors=[color_map.get(x, '#E0EDF4') for x in exp_dist['expense_type']])
153
  )])
154
+ f3.update_layout(title="Komposisi Pengeluaran (Gaya Hidup vs Kebutuhan)")
155
 
156
  return f1, f2, f3
157
 
158
  # --- UI LOGIC ---
159
+ engine = ArchonPlatinumEngine()
160
 
161
  def run_app(cust_id):
162
  res = engine.analyze(cust_id)
163
+ if not res: return "## ❌ ID Tidak Valid", "Mohon gunakan ID C0001 - C0120", None, None, None
164
 
165
+ risk_lv, score, er, u_bal, u_txn, exp, inc, action, desc, bt_s, od_s, mp_s = res
166
+ report = engine.build_narrative(risk_lv, score, er, u_bal, exp, inc, action, desc, cust_id, u_txn)
167
+ p1, p2, p3 = engine.create_plots(u_bal, u_txn)
168
 
169
  color = "#ef4444" if risk_lv == "HIGH" else ("#f59e0b" if risk_lv == "MEDIUM" else "#10b981")
170
  sidebar = f"""
171
+ <div class='sidebar-nagari'>
172
+ <h2 style='color: #05A4DE; margin:0;'>Dashboard Ringkasan</h2>
173
+ <div style='background:{color}; color:white; padding:10px 20px; border-radius:30px; font-weight:bold; display:inline-block; margin:15px 0;'>{risk_lv} RISK LEVEL</div>
174
+ <div class='health-badge'><b>Risk Score:</b> {score:.2f} / 1.00</div>
175
+ <div class='health-badge'><b>Expense Ratio:</b> {er:.1%} {'⚠️' if er > 0.8 else '✔️'}</div>
176
+ <div class='health-badge'><b>Balance Trend:</b> {'🔻 Turun' if bt_s == 1 else '🔺 Stabil'}</div>
177
  </div>
178
  """
179
  return sidebar, report, p1, p2, p3
 
192
  with gr.Tab("Visual Analytics"):
193
  gr.Markdown("### 1. Inflow vs Outflow")
194
  plot_cf = gr.Plot()
 
195
  gr.Markdown("---")
196
+ gr.Markdown("### 2. Tren Pertumbuhan Saldo")
197
  plot_bal = gr.Plot()
 
198
  gr.Markdown("---")
199
  gr.Markdown("### 3. Gaya Hidup (Essential vs Discretionary)")
200
  plot_dist = gr.Plot()
201
+ gr.HTML("<div style='background:#f1f5f9; padding:15px; border-radius:8px;'><b>Warna:</b> Biru = Kebutuhan Pokok (Essential). Emas = Gaya Hidup (Discretionary).</div>")
202
 
203
  btn.click(fn=run_app, inputs=id_in, outputs=[out_side, out_report, plot_cf, plot_bal, plot_dist])
204