SuriRaja commited on
Commit
5735a6e
Β·
verified Β·
1 Parent(s): b5fda65

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +159 -26
app.py CHANGED
@@ -1,10 +1,11 @@
1
- # Smart Office Attendance β€” Patched + Tabs (Employees, Reports)
2
- # - Live tab: your original pipeline (YOLOv8 + MediaPipe) with W|I|P|S counters,
3
  # per-employee check-in/out, event cooldown, batched events, metrics, CSV outputs.
4
  # - Employees tab: table of live counters & status; search; CSV export.
5
  # - Reports tab: charts + report builder from real session data; CSV export.
 
6
  # - Face DB: loads ALL subfolders under ./employees (each subfolder = employee name).
7
- # - Salesforce: **disabled** (functions retained; they log locally & write to CSV lists).
8
  # ------------------------------------------------------------------------------------
9
 
10
  import os, io, json, math, time, tempfile, traceback, uuid
@@ -20,10 +21,11 @@ from collections import deque
20
 
21
  # ------------------------- Runtime switches -------------------------
22
  ENABLE_SF = False # πŸ”• keep SF code paths but do not call API
 
23
  MAX_PREVIEW_W = 1280
24
  FACE_UPDATE_EVERY_N = 4
25
  EVENT_COOLDOWN_SEC = 1.0
26
- PHONE_PERSIST_N = 3
27
  SLEEP_IDLE_SECONDS = 60.0
28
  CHECKOUT_MISS_FRAMES = 90
29
 
@@ -35,7 +37,7 @@ DEFAULT_CAMERA_ID = "CAM01"
35
  # ------------------------- Page & Tabs -------------------------
36
  st.set_page_config(page_title="Smart Office Attendance", page_icon="πŸŽ₯", layout="wide")
37
 
38
- tabs = st.tabs(["🟒 Live", "πŸ‘₯ Employees", "πŸ“ˆ Reports"])
39
 
40
  # ------------------------- lazy model imports -------------------------
41
  @st.cache_resource(show_spinner=False)
@@ -81,6 +83,8 @@ st.session_state.setdefault("logs", deque(maxlen=400))
81
  st.session_state.setdefault("last_emit_map", {})
82
  st.session_state.setdefault("attendance_rows", []) # local CSV for attendance
83
  st.session_state.setdefault("metric_rows", []) # local CSV for metrics (session appends)
 
 
84
 
85
  def log_ui(line, ok=True):
86
  col = "🟒" if ok else "πŸ”΄"
@@ -204,17 +208,20 @@ def center_speed(cur_box, prev_box):
204
  return math.hypot(cx1 - cx0, cy1 - cy0)
205
 
206
  def phone_near_head(person_box, phone_boxes, face_boxes):
 
207
  px1, py1, px2, py2 = person_box
208
- head_h = int(py1 + 0.45 * (py2 - py1))
209
  head_box = (px1, py1, px2, head_h)
210
  exp_faces = []
211
  for f in face_boxes:
212
  fx1, fy1, fx2, fy2 = f
213
  w = fx2 - fx1; h = fy2 - fy1
214
- exp_faces.append((fx1 - w//6, fy1 - h//6, fx2 + w//6, fy2 + h//6))
215
  for ph in phone_boxes:
216
- if any(iou(ph.box, f) > 0.05 for f in exp_faces): return True
217
- if iou(ph.box, head_box) > 0.07: return True
 
 
218
  return False
219
 
220
  def recognize_from_db(face_emb, db, threshold=0.70):
@@ -277,10 +284,17 @@ def save_metrics_csv() -> str:
277
  df.to_csv(out_path, index=False)
278
  return out_path
279
 
 
 
 
 
 
 
 
 
280
  # ------------------------- β€œSalesforce” shims (disabled) -------------------------
281
  def push_events_batch(rows):
282
  if not rows: return
283
- # Just log locally
284
  log_ui(f"(SF disabled) queued {len(rows)} Event rows to local CSV.", ok=True)
285
 
286
  def push_metric_batch(rows):
@@ -289,7 +303,6 @@ def push_metric_batch(rows):
289
  log_ui(f"(SF disabled) added {len(rows)} Metric rows to local CSV.", ok=True)
290
 
291
  def push_attendance_once(kind: str, employee_name: str, ts_iso: str):
292
- # Store to local list so we can export CSV later
293
  st.session_state.attendance_rows.append({
294
  "Type": kind, "EmployeeName": employee_name, "InTime": ts_iso
295
  })
@@ -331,6 +344,39 @@ with tabs[0]:
331
  if low == "working": return "Working"
332
  return "Idle"
333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  def process_video(file_or_path, conf_thres: float, fps_fraction: float, idle_motion_px: int):
335
  st.session_state.current_run_id = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
336
  run_id = st.session_state.current_run_id
@@ -449,6 +495,8 @@ with tabs[0]:
449
  else:
450
  if (time.time() - st.session_state.idle_start_ts[name]) >= SLEEP_IDLE_SECONDS:
451
  act = "Sleep"
 
 
452
  else:
453
  st.session_state.idle_start_ts[name] = time.time()
454
 
@@ -457,11 +505,16 @@ with tabs[0]:
457
  elif act == "Sleep":
458
  c["sleep"] = c.get("sleep",0.0) + dt
459
 
460
- # phone nanosecond timer
461
  now_ns = time.time_ns()
462
  if act == "On Phone":
463
  if st.session_state.on_phone_start_ns.get(name) is None:
464
  st.session_state.on_phone_start_ns[name] = now_ns
 
 
 
 
 
465
  else:
466
  if st.session_state.on_phone_start_ns.get(name) is not None:
467
  st.session_state.on_phone_accum_ns[name] = int(st.session_state.on_phone_accum_ns.get(name,0)) + (now_ns - int(st.session_state.on_phone_start_ns[name]))
@@ -479,8 +532,8 @@ with tabs[0]:
479
  key = f"{DEFAULT_CAMERA_ID}|{DEFAULT_ZONE}|{name}|{act}"
480
  can_emit = True
481
  last = st.session_state.last_emit_map
482
- last_t = last.get(key); now_t = time.time()
483
- if last_t and (now_t - last_t) < EVENT_COOLDOWN_SEC:
484
  can_emit = False
485
  if can_emit:
486
  st.session_state.events = pd.concat([st.session_state.events, pd.DataFrame([{
@@ -505,9 +558,9 @@ with tabs[0]:
505
  "IdleUs__c": int(c.get("idle", 0.0) * 1_000_000),
506
  "Working_Seconds__c": int(c.get("working", 0.0)),
507
  })
508
- last[key] = now_t
509
 
510
- # draw overlay
511
  draw_act = act if act in ALLOWED_ACT else "Idle"
512
  color = ACT_COLORS.get(draw_act, (120,120,120))
513
  x1,y1,x2,y2 = p.box
@@ -517,14 +570,12 @@ with tabs[0]:
517
  iv = fmt_secs_short(c.get("idle",0.0))
518
  pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
519
  if st.session_state.on_phone_start_ns.get(name) is not None:
520
- pn_ns += (time.time_ns() - int(st.session_state.on_phone_start_ns[name]))
 
521
  pv = fmt_secs_short(pn_ns/1e9)
522
  sv = fmt_secs_short(c.get("sleep",0.0))
523
- top = max(0, y1-24)
524
  tag = f"{name} [{draw_act}] W|I|P|S = {wv}|{iv}|{pv}|{sv}"
525
- tw = 10 + 7*len(tag)
526
- cv2.rectangle(vis, (x1, top), (x1+min(max(200,tw), x2-x1+120), top+20), (250,250,250), -1)
527
- cv2.putText(vis, tag, (x1+5, top+14), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (30,30,30), 1, cv2.LINE_AA)
528
 
529
  # checkouts (not seen recently)
530
  gone = []
@@ -561,7 +612,7 @@ with tabs[0]:
561
  for emp, c in st.session_state.emp_counters.items():
562
  pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0))
563
  if st.session_state.on_phone_start_ns.get(emp) is not None:
564
- pn_ns += (time.time_ns() - int(st.session_state.on_phone_start_ns[emp]))
565
  metric_rows.append({
566
  "As_Of_Date__c": date.today().isoformat(),
567
  "Working_Sec__c": int(c.get("working",0.0)),
@@ -579,13 +630,15 @@ with tabs[0]:
579
  sum_csv = save_run_summary_csv(run_id, f"Floor {DEFAULT_FLOOR}", DEFAULT_CAMERA_ID)
580
  att_csv = save_attendance_csv()
581
  met_csv = save_metrics_csv()
 
582
 
583
  st.success(f"Processed frames: {frame_no}")
584
- c1,c2,c3,c4 = st.columns(4)
585
  if ev_csv: c1.download_button("Download Events CSV", data=open(ev_csv,"rb").read(), file_name=Path(ev_csv).name)
586
  if sum_csv: c2.download_button("Download Summary CSV", data=open(sum_csv,"rb").read(), file_name=Path(sum_csv).name)
587
  if att_csv: c3.download_button("Download Attendance CSV", data=open(att_csv,"rb").read(), file_name=Path(att_csv).name)
588
  if met_csv: c4.download_button("Download Metrics CSV", data=open(met_csv,"rb").read(), file_name=Path(met_csv).name)
 
589
 
590
  if process_btn:
591
  try:
@@ -611,7 +664,7 @@ with tabs[1]:
611
  for name, c in sorted(st.session_state.emp_counters.items()):
612
  pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
613
  if st.session_state.on_phone_start_ns.get(name) is not None:
614
- pn_ns += (time.time_ns() - int(st.session_state.on_phone_start_ns[name]))
615
  rows.append({
616
  "Employee": name,
617
  "Working (sec)": int(c.get("working",0.0)),
@@ -662,7 +715,7 @@ with tabs[2]:
662
 
663
  st.divider()
664
 
665
- # Activity by employee (stacked-like table + bar)
666
  if not ev.empty:
667
  pivot = ev.groupby(["employee","activity"]).size().unstack(fill_value=0)
668
  st.subheader("By Employee Γ— Activity")
@@ -695,7 +748,6 @@ with tabs[2]:
695
  all_emps = sorted(ev["employee"].unique().tolist()) if not ev.empty else []
696
  sel_emp = ecol1.selectbox("Employee", options=["All"]+all_emps, index=0)
697
  sel_act = ecol2.selectbox("Activity", options=["All"]+list(ALLOWED_ACT.keys()), index=0)
698
- # zone placeholder
699
  sel_zone = ecol3.selectbox("Zone", options=["All"]+sorted(ev["zone"].unique()) if not ev.empty else ["All"], index=0)
700
 
701
  # Build filtered table
@@ -724,4 +776,85 @@ with tabs[2]:
724
  if not (att_csv or met_csv):
725
  st.info("Attendance/Metrics CSVs will show after a Live run.")
726
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727
  # ------------------------- end of file -------------------------
 
1
+ # Smart Office Attendance β€” Patched + Tabs (+ Anomalies)
2
+ # - Live tab: original pipeline (YOLOv8 + MediaPipe) with W|I|P|S counters,
3
  # per-employee check-in/out, event cooldown, batched events, metrics, CSV outputs.
4
  # - Employees tab: table of live counters & status; search; CSV export.
5
  # - Reports tab: charts + report builder from real session data; CSV export.
6
+ # - Anomalies tab: auto-surface Sleep cases -> Admin can raise, comment, resolve (local-only).
7
  # - Face DB: loads ALL subfolders under ./employees (each subfolder = employee name).
8
+ # - Salesforce: disabled (code paths retained; everything stored locally to CSV lists).
9
  # ------------------------------------------------------------------------------------
10
 
11
  import os, io, json, math, time, tempfile, traceback, uuid
 
21
 
22
  # ------------------------- Runtime switches -------------------------
23
  ENABLE_SF = False # πŸ”• keep SF code paths but do not call API
24
+
25
  MAX_PREVIEW_W = 1280
26
  FACE_UPDATE_EVERY_N = 4
27
  EVENT_COOLDOWN_SEC = 1.0
28
+ PHONE_PERSIST_N = 2 # ↓ from 3 to 2 so "On Phone" stabilizes quicker
29
  SLEEP_IDLE_SECONDS = 60.0
30
  CHECKOUT_MISS_FRAMES = 90
31
 
 
37
  # ------------------------- Page & Tabs -------------------------
38
  st.set_page_config(page_title="Smart Office Attendance", page_icon="πŸŽ₯", layout="wide")
39
 
40
+ tabs = st.tabs(["🟒 Live", "πŸ‘₯ Employees", "πŸ“ˆ Reports", "🚩 Anomalies"])
41
 
42
  # ------------------------- lazy model imports -------------------------
43
  @st.cache_resource(show_spinner=False)
 
83
  st.session_state.setdefault("last_emit_map", {})
84
  st.session_state.setdefault("attendance_rows", []) # local CSV for attendance
85
  st.session_state.setdefault("metric_rows", []) # local CSV for metrics (session appends)
86
+ st.session_state.setdefault("anomalies", []) # anomaly tickets (local workflow)
87
+ st.session_state.setdefault("anomaly_counter", 1) # incremental id
88
 
89
  def log_ui(line, ok=True):
90
  col = "🟒" if ok else "πŸ”΄"
 
208
  return math.hypot(cx1 - cx0, cy1 - cy0)
209
 
210
  def phone_near_head(person_box, phone_boxes, face_boxes):
211
+ """Phone considered 'near head' if overlaps any expanded face box or upper half of the person box."""
212
  px1, py1, px2, py2 = person_box
213
+ head_h = int(py1 + 0.55 * (py2 - py1)) # a bit deeper into upper body for robustness
214
  head_box = (px1, py1, px2, head_h)
215
  exp_faces = []
216
  for f in face_boxes:
217
  fx1, fy1, fx2, fy2 = f
218
  w = fx2 - fx1; h = fy2 - fy1
219
+ exp_faces.append((fx1 - w//5, fy1 - h//5, fx2 + w//5, fy2 + h//5))
220
  for ph in phone_boxes:
221
+ if any(iou(ph.box, f) > 0.04 for f in exp_faces): # slightly looser
222
+ return True
223
+ if iou(ph.box, head_box) > 0.06:
224
+ return True
225
  return False
226
 
227
  def recognize_from_db(face_emb, db, threshold=0.70):
 
284
  df.to_csv(out_path, index=False)
285
  return out_path
286
 
287
+ def save_anomalies_csv() -> str:
288
+ rows = st.session_state.anomalies
289
+ if not rows: return ""
290
+ df = pd.DataFrame(rows)
291
+ out_path = "/tmp/anomalies_today.csv"
292
+ df.to_csv(out_path, index=False)
293
+ return out_path
294
+
295
  # ------------------------- β€œSalesforce” shims (disabled) -------------------------
296
  def push_events_batch(rows):
297
  if not rows: return
 
298
  log_ui(f"(SF disabled) queued {len(rows)} Event rows to local CSV.", ok=True)
299
 
300
  def push_metric_batch(rows):
 
303
  log_ui(f"(SF disabled) added {len(rows)} Metric rows to local CSV.", ok=True)
304
 
305
  def push_attendance_once(kind: str, employee_name: str, ts_iso: str):
 
306
  st.session_state.attendance_rows.append({
307
  "Type": kind, "EmployeeName": employee_name, "InTime": ts_iso
308
  })
 
344
  if low == "working": return "Working"
345
  return "Idle"
346
 
347
+ def _overlay_label(img, x1, y1, text):
348
+ """Draw a white box sized to text using cv2.getTextSize and put text on top."""
349
+ font = cv2.FONT_HERSHEY_SIMPLEX
350
+ scale = 0.45
351
+ thickness = 1
352
+ (tw, th), baseline = cv2.getTextSize(text, font, scale, thickness)
353
+ pad_x, pad_y = 8, 6
354
+ box_w = tw + 2*pad_x
355
+ box_h = th + baseline + 2*pad_y
356
+ top = max(0, y1 - (box_h + 4))
357
+ cv2.rectangle(img, (x1, top), (x1 + box_w, top + box_h), (250,250,250), -1)
358
+ cv2.putText(img, text, (x1 + pad_x, top + pad_y + th), font, scale, (30,30,30), thickness, cv2.LINE_AA)
359
+
360
+ def _open_ticket_if_sleep(name: str, seconds_sleep: float):
361
+ # Auto-surface as an "observed" anomaly if Sleep exceeds threshold within the run
362
+ if seconds_sleep >= SLEEP_IDLE_SECONDS:
363
+ # avoid duplicates: only one open ticket per run+name
364
+ open_exists = any((t["employee"] == name and t["status"] == "Open") for t in st.session_state.anomalies)
365
+ if not open_exists:
366
+ ticket_id = st.session_state.anomaly_counter
367
+ st.session_state.anomaly_counter += 1
368
+ st.session_state.anomalies.append({
369
+ "ticket_id": ticket_id,
370
+ "employee": name,
371
+ "type": "Sleep",
372
+ "observed_sec": int(seconds_sleep),
373
+ "raised_by": "Admin",
374
+ "raised_at": datetime.utcnow().isoformat() + "Z",
375
+ "status": "Open",
376
+ "history": [{"at": datetime.utcnow().isoformat()+"Z", "by": "System", "msg": f"Auto-detected Sleep >= {SLEEP_IDLE_SECONDS}s"}],
377
+ })
378
+ log_ui(f"Anomaly opened for {name} (Sleep)", ok=False)
379
+
380
  def process_video(file_or_path, conf_thres: float, fps_fraction: float, idle_motion_px: int):
381
  st.session_state.current_run_id = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
382
  run_id = st.session_state.current_run_id
 
495
  else:
496
  if (time.time() - st.session_state.idle_start_ts[name]) >= SLEEP_IDLE_SECONDS:
497
  act = "Sleep"
498
+ # surface anomaly candidate
499
+ _open_ticket_if_sleep(name, c.get("sleep", 0.0))
500
  else:
501
  st.session_state.idle_start_ts[name] = time.time()
502
 
 
505
  elif act == "Sleep":
506
  c["sleep"] = c.get("sleep",0.0) + dt
507
 
508
+ # phone nanosecond timer (continuous accumulation)
509
  now_ns = time.time_ns()
510
  if act == "On Phone":
511
  if st.session_state.on_phone_start_ns.get(name) is None:
512
  st.session_state.on_phone_start_ns[name] = now_ns
513
+ else:
514
+ # accumulate while active so Employees/overlay keep increasing smoothly
515
+ delta = now_ns - int(st.session_state.on_phone_start_ns[name])
516
+ st.session_state.on_phone_accum_ns[name] = int(st.session_state.on_phone_accum_ns.get(name,0)) + delta
517
+ st.session_state.on_phone_start_ns[name] = now_ns
518
  else:
519
  if st.session_state.on_phone_start_ns.get(name) is not None:
520
  st.session_state.on_phone_accum_ns[name] = int(st.session_state.on_phone_accum_ns.get(name,0)) + (now_ns - int(st.session_state.on_phone_start_ns[name]))
 
532
  key = f"{DEFAULT_CAMERA_ID}|{DEFAULT_ZONE}|{name}|{act}"
533
  can_emit = True
534
  last = st.session_state.last_emit_map
535
+ last_t = last.get(key); now_t2 = time.time()
536
+ if last_t and (now_t2 - last_t) < EVENT_COOLDOWN_SEC:
537
  can_emit = False
538
  if can_emit:
539
  st.session_state.events = pd.concat([st.session_state.events, pd.DataFrame([{
 
558
  "IdleUs__c": int(c.get("idle", 0.0) * 1_000_000),
559
  "Working_Seconds__c": int(c.get("working", 0.0)),
560
  })
561
+ last[key] = now_t2
562
 
563
+ # draw overlay (box width computed from text size)
564
  draw_act = act if act in ALLOWED_ACT else "Idle"
565
  color = ACT_COLORS.get(draw_act, (120,120,120))
566
  x1,y1,x2,y2 = p.box
 
570
  iv = fmt_secs_short(c.get("idle",0.0))
571
  pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
572
  if st.session_state.on_phone_start_ns.get(name) is not None:
573
+ # the continuous accumulation path already bumps the counter each frame
574
+ pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
575
  pv = fmt_secs_short(pn_ns/1e9)
576
  sv = fmt_secs_short(c.get("sleep",0.0))
 
577
  tag = f"{name} [{draw_act}] W|I|P|S = {wv}|{iv}|{pv}|{sv}"
578
+ _overlay_label(vis, x1, y1, tag)
 
 
579
 
580
  # checkouts (not seen recently)
581
  gone = []
 
612
  for emp, c in st.session_state.emp_counters.items():
613
  pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0))
614
  if st.session_state.on_phone_start_ns.get(emp) is not None:
615
+ pn_ns = int(st.session_state.on_phone_accum_ns.get(emp,0)) # already accumulated
616
  metric_rows.append({
617
  "As_Of_Date__c": date.today().isoformat(),
618
  "Working_Sec__c": int(c.get("working",0.0)),
 
630
  sum_csv = save_run_summary_csv(run_id, f"Floor {DEFAULT_FLOOR}", DEFAULT_CAMERA_ID)
631
  att_csv = save_attendance_csv()
632
  met_csv = save_metrics_csv()
633
+ an_csv = save_anomalies_csv()
634
 
635
  st.success(f"Processed frames: {frame_no}")
636
+ c1,c2,c3,c4,c5 = st.columns(5)
637
  if ev_csv: c1.download_button("Download Events CSV", data=open(ev_csv,"rb").read(), file_name=Path(ev_csv).name)
638
  if sum_csv: c2.download_button("Download Summary CSV", data=open(sum_csv,"rb").read(), file_name=Path(sum_csv).name)
639
  if att_csv: c3.download_button("Download Attendance CSV", data=open(att_csv,"rb").read(), file_name=Path(att_csv).name)
640
  if met_csv: c4.download_button("Download Metrics CSV", data=open(met_csv,"rb").read(), file_name=Path(met_csv).name)
641
+ if an_csv: c5.download_button("Download Anomalies CSV", data=open(an_csv,"rb").read(), file_name=Path(an_csv).name)
642
 
643
  if process_btn:
644
  try:
 
664
  for name, c in sorted(st.session_state.emp_counters.items()):
665
  pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
666
  if st.session_state.on_phone_start_ns.get(name) is not None:
667
+ pn_ns = int(st.session_state.on_phone_accum_ns.get(name,0))
668
  rows.append({
669
  "Employee": name,
670
  "Working (sec)": int(c.get("working",0.0)),
 
715
 
716
  st.divider()
717
 
718
+ # Activity by employee
719
  if not ev.empty:
720
  pivot = ev.groupby(["employee","activity"]).size().unstack(fill_value=0)
721
  st.subheader("By Employee Γ— Activity")
 
748
  all_emps = sorted(ev["employee"].unique().tolist()) if not ev.empty else []
749
  sel_emp = ecol1.selectbox("Employee", options=["All"]+all_emps, index=0)
750
  sel_act = ecol2.selectbox("Activity", options=["All"]+list(ALLOWED_ACT.keys()), index=0)
 
751
  sel_zone = ecol3.selectbox("Zone", options=["All"]+sorted(ev["zone"].unique()) if not ev.empty else ["All"], index=0)
752
 
753
  # Build filtered table
 
776
  if not (att_csv or met_csv):
777
  st.info("Attendance/Metrics CSVs will show after a Live run.")
778
 
779
+ # ------------------------- ANOMALIES TAB -------------------------
780
+ with tabs[3]:
781
+ st.title("🚩 Anomalies (Admin)")
782
+ st.caption("Sleep cases auto-surface here. Admin can raise to employee, add notes, and mark resolved.")
783
+
784
+ # Quick raise form (manual)
785
+ with st.expander("Raise manual anomaly", expanded=False):
786
+ name_opt = sorted(list(st.session_state.emp_counters.keys()))
787
+ emp = st.selectbox("Employee", options=name_opt if name_opt else ["β€”"], index=0 if name_opt else 0, key="an_raise_emp")
788
+ typ = st.selectbox("Type", options=["Sleep"], index=0, key="an_raise_type")
789
+ sec = st.number_input("Observed seconds", min_value=0, value=60, step=5, key="an_raise_sec")
790
+ note = st.text_input("Note (optional)", "", key="an_raise_note")
791
+ if st.button("Create Ticket", key="an_raise_btn", disabled=not name_opt):
792
+ ticket_id = st.session_state.anomaly_counter
793
+ st.session_state.anomaly_counter += 1
794
+ st.session_state.anomalies.append({
795
+ "ticket_id": ticket_id,
796
+ "employee": emp,
797
+ "type": typ,
798
+ "observed_sec": int(sec),
799
+ "raised_by": "Admin",
800
+ "raised_at": datetime.utcnow().isoformat() + "Z",
801
+ "status": "Open",
802
+ "history": [{"at": datetime.utcnow().isoformat()+"Z", "by":"Admin", "msg": note or f"Manual {typ}"}],
803
+ })
804
+ st.success(f"Ticket #{ticket_id} created for {emp}")
805
+
806
+ # Tickets table
807
+ an_rows = st.session_state.anomalies
808
+ if an_rows:
809
+ df_an = pd.DataFrame([
810
+ {
811
+ "Ticket": r["ticket_id"],
812
+ "Employee": r["employee"],
813
+ "Type": r["type"],
814
+ "Observed (sec)": r.get("observed_sec", 0),
815
+ "Raised By": r["raised_by"],
816
+ "Raised At": r["raised_at"],
817
+ "Status": r["status"],
818
+ } for r in an_rows
819
+ ])
820
+ st.dataframe(df_an, use_container_width=True, hide_index=True)
821
+
822
+ # actions
823
+ st.subheader("Ticket actions")
824
+ ids = [r["ticket_id"] for r in an_rows]
825
+ sel = st.selectbox("Select ticket", options=ids, index=0)
826
+ action_col1, action_col2 = st.columns(2)
827
+ with action_col1:
828
+ msg = st.text_input("Add note / employee response", "", key="an_msg")
829
+ if st.button("Add Note", key="an_add_note"):
830
+ for r in an_rows:
831
+ if r["ticket_id"] == sel:
832
+ r["history"].append({"at": datetime.utcnow().isoformat()+"Z", "by":"Admin", "msg": msg or "(no text)"})
833
+ st.success("Note added")
834
+ break
835
+ with action_col2:
836
+ if st.button("Mark Resolved", key="an_resolve"):
837
+ for r in an_rows:
838
+ if r["ticket_id"] == sel:
839
+ r["status"] = "Resolved"
840
+ r["history"].append({"at": datetime.utcnow().isoformat()+"Z", "by":"Admin", "msg":"Resolved"})
841
+ st.success("Ticket resolved")
842
+ break
843
+
844
+ csv_an = save_anomalies_csv()
845
+ if csv_an:
846
+ st.download_button("Export Anomalies CSV", data=open(csv_an,"rb").read(), file_name=Path(csv_an).name)
847
+ st.divider()
848
+
849
+ # show history
850
+ st.subheader("Ticket history")
851
+ hsel = st.selectbox("Select ticket to view history", options=ids, index=0, key="an_hist_sel")
852
+ for r in an_rows:
853
+ if r["ticket_id"] == hsel:
854
+ for entry in r["history"]:
855
+ st.write(f"- **{entry['at']}** β€’ {entry['by']}: {entry['msg']}")
856
+ break
857
+ else:
858
+ st.info("No anomalies yet. Sleep events will auto-create tickets once thresholds are crossed.")
859
+
860
  # ------------------------- end of file -------------------------