IAMTFRMZA commited on
Commit
fa1e3ad
Β·
verified Β·
1 Parent(s): d3b24ed

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +154 -183
app.py CHANGED
@@ -1,149 +1,156 @@
1
  import gradio as gr
2
  import pandas as pd
3
  import gspread
4
- from gspread.exceptions import WorksheetNotFound
5
  from oauth2client.service_account import ServiceAccountCredentials
6
  from datetime import datetime, timedelta
7
- from gspread_dataframe import set_with_dataframe
8
 
9
  # -------------------- CONFIG --------------------
10
- SHEET_URL = "https://docs.google.com/spreadsheets/d/1if4KoVQvw5ZbhknfdZbzMkcTiPfsD6bz9V3a1th-bwQ"
11
- USER_SHEET_NAME = "Users" # <-- must match (or contain) your β€œUsers” tab
12
 
13
  # -------------------- AUTH --------------------
14
  scope = [
15
  "https://spreadsheets.google.com/feeds",
16
- "https://www.googleapis.com/auth/drive",
17
  ]
18
- creds = ServiceAccountCredentials.from_json_keyfile_name(
19
- "deep-mile-461309-t8-0e90103411e0.json", scope
20
- )
21
  client = gspread.authorize(creds)
22
 
23
- # -------------------- FUZZY WORKSHEET LOOKUP --------------------
24
- def open_ws_by_substring(substr: str):
25
- """
26
- Try exact match, then fall back to the first worksheet
27
- whose title contains `substr` (case-insensitive).
28
- """
29
- sh = client.open_by_url(SHEET_URL)
30
- try:
31
- return sh.worksheet(substr)
32
- except WorksheetNotFound:
33
- for ws in sh.worksheets():
34
- if substr.lower() in ws.title.lower():
35
- return ws
36
- raise WorksheetNotFound(f"No tab matching '{substr}'")
37
-
38
- # -------------------- SHEET UTILS --------------------
39
- def normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
40
  df.columns = df.columns.str.strip().str.title()
41
  return df
42
 
43
- def load_sheet(sheet_name: str) -> pd.DataFrame:
44
- """Return a DataFrame of the entire sheet, or an Error row."""
45
  try:
46
- ws = open_ws_by_substring(sheet_name)
47
- df = pd.DataFrame(ws.get_all_records())
 
48
  return normalize_columns(df)
49
  except Exception as e:
 
50
  return pd.DataFrame([{"Error": str(e)}])
51
 
52
- def load_sheet_df(sheet_name: str) -> pd.DataFrame:
53
- """Like load_sheet, but lets WorksheetNotFound bubble up."""
54
- ws = open_ws_by_substring(sheet_name)
55
- df = pd.DataFrame(ws.get_all_records())
56
- return normalize_columns(df)
57
-
58
- # -------------------- DATE FILTER HELPERS --------------------
59
  def get_current_week_range():
60
- today = datetime.now().date()
61
  start = today - timedelta(days=today.weekday())
62
  end = start + timedelta(days=6)
63
- return start, end
64
-
65
- def filter_week(df, date_col, rep_col=None, rep=None):
66
- df[date_col] = pd.to_datetime(df[date_col], errors="coerce").dt.date
67
- start, end = get_current_week_range()
68
- out = df[(df[date_col] >= start) & (df[date_col] <= end)]
69
- if rep:
70
- out = out[out[rep_col] == rep]
71
  return out
72
 
73
- def filter_date(df, date_col, rep_col, y, m, d, rep):
74
  try:
75
  target = datetime(int(y), int(m), int(d)).date()
76
  except:
77
- return pd.DataFrame([{"Error": "Invalid date"}])
78
- df[date_col] = pd.to_datetime(df[date_col], errors="coerce").dt.date
79
- out = df[df[date_col] == target]
80
- if rep:
81
- out = out[out[rep_col] == rep]
82
  return out
83
 
84
- # -------------------- REPORT FUNCTIONS --------------------
85
  def get_calls(rep=None):
86
- df = load_sheet("Calls")
87
  if "Call Date" not in df.columns:
88
- return df
89
  return filter_week(df, "Call Date", "Rep", rep)
90
 
91
- def search_calls_by_date(y, m, d, rep):
92
- df = load_sheet("Calls")
93
- if "Call Date" not in df.columns:
94
  return df
95
- return filter_date(df, "Call Date", "Rep", y, m, d, rep)
 
 
 
 
 
 
 
 
 
 
 
96
 
97
  def get_appointments(rep=None):
98
- df = load_sheet("Appointments")
99
  if "Appointment Date" not in df.columns:
100
- return df
101
  return filter_week(df, "Appointment Date", "Rep", rep)
102
 
103
- def search_appointments_by_date(y, m, d, rep):
104
- df = load_sheet("Appointments")
105
- if "Appointment Date" not in df.columns:
106
  return df
107
- return filter_date(df, "Appointment Date", "Rep", y, m, d, rep)
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  def get_leads_detail():
110
- return load_sheet("AllocatedLeads")
 
 
 
 
 
111
 
112
  def get_leads_summary():
113
  df = get_leads_detail()
114
  if "Error" in df.columns:
115
  return df
116
- return df.groupby("Assigned Rep")\
117
- .size()\
118
- .reset_index(name="Leads Count")
119
 
 
120
  def compute_insights():
121
  calls = get_calls()
122
  appts = get_appointments()
123
  leads = get_leads_detail()
124
 
125
  def top_rep(df, col):
126
- if "Error" in df.columns or df.empty or col not in df.columns:
127
  return "N/A"
128
- return df.groupby(col).size().idxmax()
 
 
 
129
 
130
- data = [
131
- {"Metric": "Most Calls This Week", "Rep": top_rep(calls, "Rep")},
132
- {"Metric": "Most Appointments This Week", "Rep": top_rep(appts, "Rep")},
133
- {"Metric": "Most Leads Allocated", "Rep": top_rep(leads, "Assigned Rep")},
134
- ]
135
- return pd.DataFrame(data)
136
 
137
- def rep_options(sheet, col):
138
- df = load_sheet(sheet)
139
- if col in df.columns:
140
- return sorted(df[col].dropna().unique().tolist())
141
- return []
142
 
143
  # -------------------- USER MANAGEMENT --------------------
144
- def load_users() -> pd.DataFrame:
145
- cols = [
146
- "ID","Email","Name","Business","Role",
 
 
147
  "Daily Phone Call Target","Daily Phone Appointment Target",
148
  "Daily Quote Number Target","Daily Quote Revenue Target",
149
  "Weekly Phone Call Target","Weekly Phone Appointment Target",
@@ -152,151 +159,115 @@ def load_users() -> pd.DataFrame:
152
  "Monthly Quote Number Target","Monthly Quote Revenue Target",
153
  "Monthly Sales Revenue Target"
154
  ]
155
- try:
156
- return load_sheet_df(USER_SHEET_NAME)
157
- except WorksheetNotFound:
158
- return pd.DataFrame(columns=cols)
159
 
160
- def save_users(df: pd.DataFrame):
161
- ws = client.open_by_url(SHEET_URL).worksheet(USER_SHEET_NAME)
162
  ws.clear()
163
  set_with_dataframe(ws, df)
164
  return "βœ… Users saved!"
165
 
166
- # -------------------- BUILD UI --------------------
167
- with gr.Blocks(title="Graffiti Admin Dashboard") as app:
168
  gr.Markdown("# πŸ“† Graffiti Admin Dashboard")
169
 
170
- # -- Calls Report --
171
  with gr.Tab("Calls Report"):
172
- rep_calls = gr.Dropdown(choices=rep_options("Calls","Rep"),
173
- label="Optional Rep Filter",
174
- allow_custom_value=True)
175
- calls_btn = gr.Button("Load Current Week Calls")
176
-
177
- with gr.Row():
178
- with gr.Column():
179
- calls_sum = gr.Dataframe(label="πŸ“Š Calls by Rep")
180
- with gr.Column():
181
- calls_det = gr.Dataframe(label="πŸ”Ž Detailed Calls")
182
 
183
  calls_btn.click(
184
- fn=lambda r: (
185
- get_calls(r).groupby("Rep")
186
- .size()
187
- .reset_index(name="Count"),
188
- get_calls(r)
189
- ),
190
  inputs=rep_calls,
191
- outputs=[calls_sum, calls_det]
192
  )
193
 
194
  gr.Markdown("### πŸ” Search Calls by Specific Date")
195
- y1 = gr.Textbox(label="Year")
196
- m1 = gr.Textbox(label="Month")
197
- d1 = gr.Textbox(label="Day")
198
- rep1 = gr.Dropdown(choices=rep_options("Calls","Rep"),
199
- label="Optional Rep Filter",
200
- allow_custom_value=True)
201
- calls_date_btn = gr.Button("Search Calls by Date")
202
-
203
- with gr.Row():
204
- with gr.Column():
205
- calls_date_sum = gr.Dataframe(label="πŸ“Š Calls by Rep on Date")
206
- with gr.Column():
207
- calls_date_det = gr.Dataframe(label="πŸ”Ž Detailed Calls on Date")
208
 
209
  calls_date_btn.click(
210
- fn=lambda y,m,d,r: (
211
- search_calls_by_date(y,m,d,r)
212
- .groupby("Rep")
213
- .size()
214
- .reset_index(name="Count"),
215
- search_calls_by_date(y,m,d,r)
216
- ),
217
  inputs=[y1,m1,d1,rep1],
218
- outputs=[calls_date_sum, calls_date_det]
219
  )
220
 
221
- # -- Appointments Report --
222
  with gr.Tab("Appointments Report"):
223
- rep_appt = gr.Dropdown(choices=rep_options("Appointments","Rep"),
224
- label="Optional Rep Filter",
225
- allow_custom_value=True)
226
- appt_btn = gr.Button("Load Current Week Appointments")
227
-
228
- with gr.Row():
229
- with gr.Column():
230
- appt_sum = gr.Dataframe(label="πŸ“Š Weekly Appointments Summary by Rep")
231
- with gr.Column():
232
- appt_det = gr.Dataframe(label="πŸ”Ž Detailed Appointments")
233
 
234
  appt_btn.click(
235
- fn=lambda r: (
236
- get_appointments(r)
237
- .groupby("Rep")
238
- .size()
239
- .reset_index(name="Count"),
240
- get_appointments(r)
241
- ),
242
  inputs=rep_appt,
243
- outputs=[appt_sum, appt_det]
244
  )
245
 
246
  gr.Markdown("### πŸ” Search Appointments by Specific Date")
247
- y2 = gr.Textbox(label="Year")
248
- m2 = gr.Textbox(label="Month")
249
- d2 = gr.Textbox(label="Day")
250
- rep2 = gr.Dropdown(choices=rep_options("Appointments","Rep"),
251
- label="Optional Rep Filter",
252
- allow_custom_value=True)
253
- appt_date_btn = gr.Button("Search Appointments by Date")
254
-
255
- with gr.Row():
256
- with gr.Column():
257
- appt_date_sum = gr.Dataframe(label="πŸ“Š Appts by Rep on Date")
258
- with gr.Column():
259
- appt_date_det = gr.Dataframe(label="πŸ”Ž Detailed Appts on Date")
260
 
261
  appt_date_btn.click(
262
  fn=lambda y,m,d,r: (
263
- search_appointments_by_date(y,m,d,r)
264
- .groupby("Rep")
265
- .size()
266
- .reset_index(name="Count"),
267
  search_appointments_by_date(y,m,d,r)
268
  ),
269
  inputs=[y2,m2,d2,rep2],
270
- outputs=[appt_date_sum, appt_date_det]
271
  )
272
 
273
- # -- Appointed Leads --
274
  with gr.Tab("Appointed Leads"):
275
- leads_btn = gr.Button("View Appointed Leads")
276
-
277
- with gr.Row():
278
- with gr.Column():
279
- leads_sum = gr.Dataframe(label="πŸ“Š Leads Count by Rep")
280
- with gr.Column():
281
- leads_det = gr.Dataframe(label="πŸ”Ž Detailed Leads")
282
 
283
  leads_btn.click(
284
  fn=lambda: (get_leads_summary(), get_leads_detail()),
285
- outputs=[leads_sum, leads_det]
286
  )
287
 
288
- # -- Insights --
289
  with gr.Tab("Insights"):
290
  insights_btn = gr.Button("Generate Insights")
291
  insights_tbl = gr.Dataframe()
 
292
  insights_btn.click(fn=compute_insights, outputs=insights_tbl)
293
 
294
- # -- User Management --
295
  with gr.Tab("User Management"):
296
- gr.Markdown("### πŸ™ Manage Users \nEdit the grid and click **Save Users**.")
297
  users_tbl = gr.Dataframe(value=load_users(), interactive=True)
298
  save_btn = gr.Button("Save Users")
299
- save_msg = gr.Textbox(interactive=False)
300
- save_btn.click(fn=save_users, inputs=users_tbl, outputs=save_msg)
 
301
 
302
- app.launch()
 
 
1
  import gradio as gr
2
  import pandas as pd
3
  import gspread
4
+ from gspread_dataframe import set_with_dataframe
5
  from oauth2client.service_account import ServiceAccountCredentials
6
  from datetime import datetime, timedelta
 
7
 
8
  # -------------------- CONFIG --------------------
9
+ SHEET_URL = "https://docs.google.com/spreadsheets/d/1if4KoVQvw5ZbhknfdZbzMkcTiPfsD6bz9V3a1th-bwQ"
10
+ CREDS_JSON = "deep-mile-461309-t8-0e90103411e0.json"
11
 
12
  # -------------------- AUTH --------------------
13
  scope = [
14
  "https://spreadsheets.google.com/feeds",
15
+ "https://www.googleapis.com/auth/drive"
16
  ]
17
+ creds = ServiceAccountCredentials.from_json_keyfile_name(CREDS_JSON, scope)
 
 
18
  client = gspread.authorize(creds)
19
 
20
+ # -------------------- SHEET LOAD/UTILS --------------------
21
+ def normalize_columns(df):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  df.columns = df.columns.str.strip().str.title()
23
  return df
24
 
25
+ def load_sheet_df(sheet_name):
 
26
  try:
27
+ ws = client.open_by_url(SHEET_URL).worksheet(sheet_name)
28
+ records= ws.get_all_records()
29
+ df = pd.DataFrame(records)
30
  return normalize_columns(df)
31
  except Exception as e:
32
+ # return a one-row DF with an Error column
33
  return pd.DataFrame([{"Error": str(e)}])
34
 
35
+ # -------------------- DATE FILTERS --------------------
 
 
 
 
 
 
36
  def get_current_week_range():
37
+ today = datetime.now()
38
  start = today - timedelta(days=today.weekday())
39
  end = start + timedelta(days=6)
40
+ return start.date(), end.date()
41
+
42
+ def filter_week(df, date_column, rep_column=None, rep=None):
43
+ df[date_column] = pd.to_datetime(df[date_column], errors="coerce").dt.date
44
+ start,end = get_current_week_range()
45
+ out = df[(df[date_column] >= start) & (df[date_column] <= end)]
46
+ if rep and rep in out.columns:
47
+ out = out[out[rep_column] == rep]
48
  return out
49
 
50
+ def filter_date(df, date_column, rep_column, y,m,d, rep):
51
  try:
52
  target = datetime(int(y), int(m), int(d)).date()
53
  except:
54
+ return pd.DataFrame([{"Error":"Invalid date"}])
55
+ df[date_column] = pd.to_datetime(df[date_column], errors="coerce").dt.date
56
+ out = df[df[date_column] == target]
57
+ if rep and rep in out.columns:
58
+ out = out[out[rep_column] == rep]
59
  return out
60
 
61
+ # -------------------- REPORT DATA --------------------
62
  def get_calls(rep=None):
63
+ df = load_sheet_df("Calls")
64
  if "Call Date" not in df.columns:
65
+ return pd.DataFrame([{"Error":"Missing 'Call Date' column"}])
66
  return filter_week(df, "Call Date", "Rep", rep)
67
 
68
+ def get_calls_summary(rep=None):
69
+ df = get_calls(rep)
70
+ if "Error" in df.columns or df.empty:
71
  return df
72
+ return (
73
+ df.groupby("Rep")
74
+ .size()
75
+ .reset_index(name="Count")
76
+ .sort_values("Count", ascending=False)
77
+ )
78
+
79
+ def search_calls_by_date(y,m,d, rep):
80
+ df = load_sheet_df("Calls")
81
+ if "Call Date" not in df.columns:
82
+ return pd.DataFrame([{"Error":"Missing 'Call Date' column"}])
83
+ return filter_date(df, "Call Date", "Rep", y,m,d, rep)
84
 
85
  def get_appointments(rep=None):
86
+ df = load_sheet_df("Appointments")
87
  if "Appointment Date" not in df.columns:
88
+ return pd.DataFrame([{"Error":"Missing 'Appointment Date' column"}])
89
  return filter_week(df, "Appointment Date", "Rep", rep)
90
 
91
+ def get_appointments_summary(rep=None):
92
+ df = get_appointments(rep)
93
+ if "Error" in df.columns or df.empty:
94
  return df
95
+ return (
96
+ df.groupby("Rep")
97
+ .size()
98
+ .reset_index(name="Count")
99
+ .sort_values("Count", ascending=False)
100
+ )
101
+
102
+ def search_appointments_by_date(y,m,d, rep):
103
+ df = load_sheet_df("Appointments")
104
+ if "Appointment Date" not in df.columns:
105
+ return pd.DataFrame([{"Error":"Missing 'Appointment Date' column"}])
106
+ return filter_date(df, "Appointment Date", "Rep", y,m,d, rep)
107
 
108
  def get_leads_detail():
109
+ df = load_sheet_df("AllocatedLeads")
110
+ # rename if needed
111
+ df = df.rename(columns={"Assigned Rep":"Assigned Rep"})
112
+ if "Assigned Rep" not in df.columns:
113
+ return pd.DataFrame([{"Error":"Missing 'Assigned Rep' col"}])
114
+ return df
115
 
116
  def get_leads_summary():
117
  df = get_leads_detail()
118
  if "Error" in df.columns:
119
  return df
120
+ return df.groupby("Assigned Rep").size().reset_index(name="Leads Count")
 
 
121
 
122
+ # -------------------- INSIGHTS --------------------
123
  def compute_insights():
124
  calls = get_calls()
125
  appts = get_appointments()
126
  leads = get_leads_detail()
127
 
128
  def top_rep(df, col):
129
+ if "Error" in df.columns or df.empty:
130
  return "N/A"
131
+ counts = df.groupby(col).size()
132
+ if counts.empty:
133
+ return "N/A"
134
+ return counts.idxmax()
135
 
136
+ top_calls = top_rep(calls, "Rep")
137
+ top_appts = top_rep(appts, "Rep")
138
+ # unify column name for leads
139
+ leads = leads.rename(columns={"Assigned Rep":"Rep"})
140
+ top_leads = top_rep(leads, "Rep")
 
141
 
142
+ return pd.DataFrame([
143
+ {"Metric":"Most Calls This Week", "Rep":top_calls},
144
+ {"Metric":"Most Appointments This Week", "Rep":top_appts},
145
+ {"Metric":"Most Leads Allocated", "Rep":top_leads},
146
+ ])
147
 
148
  # -------------------- USER MANAGEMENT --------------------
149
+ def load_users():
150
+ df = load_sheet_df("userAccess") # your actual tab name
151
+ # pick & title-case only the cols you want
152
+ wanted = [
153
+ "Id","Email","Name","Business","Role",
154
  "Daily Phone Call Target","Daily Phone Appointment Target",
155
  "Daily Quote Number Target","Daily Quote Revenue Target",
156
  "Weekly Phone Call Target","Weekly Phone Appointment Target",
 
159
  "Monthly Quote Number Target","Monthly Quote Revenue Target",
160
  "Monthly Sales Revenue Target"
161
  ]
162
+ exist = [c for c in wanted if c in df.columns]
163
+ return df[exist]
 
 
164
 
165
+ def save_users(df):
166
+ ws = client.open_by_url(SHEET_URL).worksheet("userAccess")
167
  ws.clear()
168
  set_with_dataframe(ws, df)
169
  return "βœ… Users saved!"
170
 
171
+ # -------------------- GRADIO UI --------------------
172
+ with gr.Blocks(title="πŸ“† Graffiti Admin Dashboard") as app:
173
  gr.Markdown("# πŸ“† Graffiti Admin Dashboard")
174
 
175
+ # ─── Calls Report ─────────────────────────────
176
  with gr.Tab("Calls Report"):
177
+ rep_calls = gr.Dropdown(
178
+ label="Optional Rep Filter",
179
+ choices=load_sheet_df("Calls")["Rep"].dropna().unique().tolist(),
180
+ allow_custom_value=True
181
+ )
182
+ calls_btn = gr.Button("Load Current Week Calls")
183
+ calls_summary = gr.Dataframe(label="πŸ“Š Calls by Rep")
184
+ calls_table = gr.Dataframe(label="πŸ”Ž Detailed Calls")
 
 
185
 
186
  calls_btn.click(
187
+ fn=lambda r: (get_calls_summary(r), get_calls(r)),
 
 
 
 
 
188
  inputs=rep_calls,
189
+ outputs=[calls_summary, calls_table]
190
  )
191
 
192
  gr.Markdown("### πŸ” Search Calls by Specific Date")
193
+ y1,m1,d1 = gr.Textbox(label="Year"), gr.Textbox(label="Month"), gr.Textbox(label="Day")
194
+ rep1 = gr.Dropdown(
195
+ label="Optional Rep Filter",
196
+ choices=load_sheet_df("Calls")["Rep"].dropna().unique().tolist(),
197
+ allow_custom_value=True
198
+ )
199
+ calls_date_btn = gr.Button("Search Calls by Date")
200
+ calls_date_table = gr.Dataframe()
 
 
 
 
 
201
 
202
  calls_date_btn.click(
203
+ fn=search_calls_by_date,
 
 
 
 
 
 
204
  inputs=[y1,m1,d1,rep1],
205
+ outputs=calls_date_table
206
  )
207
 
208
+ # ─── Appointments Report ─────────────────────
209
  with gr.Tab("Appointments Report"):
210
+ rep_appt = gr.Dropdown(
211
+ label="Optional Rep Filter",
212
+ choices=load_sheet_df("Appointments")["Rep"].dropna().unique().tolist(),
213
+ allow_custom_value=True
214
+ )
215
+ appt_btn = gr.Button("Load Current Week Appointments")
216
+ appt_summary = gr.Dataframe(label="πŸ“Š Appts by Rep")
217
+ appt_table = gr.Dataframe(label="πŸ”Ž Detailed Appointments")
 
 
218
 
219
  appt_btn.click(
220
+ fn=lambda r: (get_appointments_summary(r), get_appointments(r)),
 
 
 
 
 
 
221
  inputs=rep_appt,
222
+ outputs=[appt_summary, appt_table]
223
  )
224
 
225
  gr.Markdown("### πŸ” Search Appointments by Specific Date")
226
+ y2,m2,d2 = gr.Textbox(label="Year"), gr.Textbox(label="Month"), gr.Textbox(label="Day")
227
+ rep2 = gr.Dropdown(
228
+ label="Optional Rep Filter",
229
+ choices=load_sheet_df("Appointments")["Rep"].dropna().unique().tolist(),
230
+ allow_custom_value=True
231
+ )
232
+ appt_date_btn = gr.Button("Search Appts by Date")
233
+ appt_date_summary = gr.Dataframe(label="πŸ“Š Appts Summary by Rep")
234
+ appt_date_table = gr.Dataframe()
 
 
 
 
235
 
236
  appt_date_btn.click(
237
  fn=lambda y,m,d,r: (
238
+ (lambda df: df.groupby("Rep").size().reset_index(name="Count"))(search_appointments_by_date(y,m,d,r)),
 
 
 
239
  search_appointments_by_date(y,m,d,r)
240
  ),
241
  inputs=[y2,m2,d2,rep2],
242
+ outputs=[appt_date_summary, appt_date_table]
243
  )
244
 
245
+ # ─── Appointed Leads ──────────────────────────
246
  with gr.Tab("Appointed Leads"):
247
+ leads_btn = gr.Button("View Appointed Leads")
248
+ leads_summary = gr.Dataframe(label="πŸ“Š Leads Count by Rep")
249
+ leads_detail = gr.Dataframe(label="πŸ”Ž Detailed Leads")
 
 
 
 
250
 
251
  leads_btn.click(
252
  fn=lambda: (get_leads_summary(), get_leads_detail()),
253
+ outputs=[leads_summary, leads_detail]
254
  )
255
 
256
+ # ─── Insights ─────────────────────────────────
257
  with gr.Tab("Insights"):
258
  insights_btn = gr.Button("Generate Insights")
259
  insights_tbl = gr.Dataframe()
260
+
261
  insights_btn.click(fn=compute_insights, outputs=insights_tbl)
262
 
263
+ # ─── User Management ──────────────────────────
264
  with gr.Tab("User Management"):
265
+ gr.Markdown("## πŸ‘€ Manage Users\nEdit/add/remove rows below, then click **Save Users**.")
266
  users_tbl = gr.Dataframe(value=load_users(), interactive=True)
267
  save_btn = gr.Button("Save Users")
268
+ status = gr.Textbox()
269
+
270
+ save_btn.click(fn=save_users, inputs=users_tbl, outputs=status)
271
 
272
+ # end Blocks
273
+ app.launch()