Dhom1 commited on
Commit
a9107fe
·
verified ·
1 Parent(s): 3052497

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +214 -72
src/streamlit_app.py CHANGED
@@ -1,9 +1,10 @@
1
  """
2
- Health Matrix – AI Command Center (Desktop Wide + Chris Wright Timeline)
3
- - Sticky Left timeline (desktop) with smooth CSS animation
4
- - Auto switch to Horizontal timeline on narrow screens
5
- - Agent Overview chips + KPIs + full-width, readable tables
6
- - No logic/API/decision changes front-end only
 
7
 
8
  Usage: streamlit run streamlit_app.py
9
  """
@@ -17,7 +18,7 @@ import pandas as pd
17
  import requests
18
  import streamlit as st
19
 
20
- # Optional: OpenAI (graceful if missing)
21
  try:
22
  import openai # type: ignore
23
  HAS_OPENAI = True
@@ -27,7 +28,7 @@ except Exception:
27
 
28
 
29
  # =============================================================================
30
- # Embedded CSS (Desktop-first, “Chris Wright” style timeline adapted)
31
  # =============================================================================
32
  _EMBEDDED_CSS = """
33
  :root{
@@ -37,7 +38,7 @@ _EMBEDDED_CSS = """
37
  }
38
 
39
  /* ==== Full-width page on desktop ==== */
40
- .main .block-container{ max-width:1600px !important; padding-left:16px; padding-right:16px }
41
 
42
  /* Base */
43
  html,body{
@@ -103,75 +104,122 @@ p{ color:var(--t2) }
103
  .overview .chip{ font-size:.9rem; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:#fff }
104
  .overview .chip b{ color:#0f172a }
105
 
106
- /* KPI */
107
- .stat-row{ display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; margin-top:10px }
108
- .stat-card{
109
- background:#fff; padding:14px; border-radius:14px; border:1px solid var(--line);
110
- box-shadow:0 6px 16px rgba(0,0,0,.06); text-align:center; transition:transform .2s ease
 
 
 
 
 
 
111
  }
112
- .stat-card:hover{ transform:scale(1.02) }
113
- .stat-icon{ font-size:22px; margin-bottom:.2rem }
114
- .stat-label{ color:#334155 }
115
- .stat-value{ font-size:1.7rem; font-weight:900 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
  /* ======================= Chris Wright Timeline (adapted) ======================= */
118
- /* Container that stays pinned on desktop */
119
  .cw-rail{ position:sticky; top:84px } /* pinned under Streamlit header */
120
  @media (max-width: 1100px){ .cw-rail{ position:static } } /* not sticky on small screens */
121
 
122
- /* Vertical timeline */
123
- .cw-timeline{
124
- position:relative; margin:0; padding:0 0 0 28px; list-style:none;
125
- }
126
- .cw-timeline::before{
127
- content:""; position:absolute; left:11px; top:0; bottom:0; width:2px; background:#E2E8F0;
128
- }
129
-
130
- /* Item */
131
  .cw-item{
132
  position:relative; margin:0 0 14px; background:#fff; border:1px solid var(--line);
133
  border-radius:14px; box-shadow:0 6px 14px rgba(0,0,0,.06);
134
- padding:10px 12px 10px 12px; opacity:0; transform:translateX(-10px);
135
- animation:cwFadeIn .45s ease-out forwards;
136
  }
137
  .cw-item:nth-child(2){ animation-delay:.05s }
138
  .cw-item:nth-child(3){ animation-delay:.1s }
139
  .cw-item:nth-child(4){ animation-delay:.15s }
140
  .cw-item:nth-child(5){ animation-delay:.2s }
141
-
142
- /* Bullet dot */
143
  .cw-item::before{
144
  content:""; position:absolute; left:-17px; top:14px; width:12px; height:12px;
145
  border-radius:50%; border:2px solid #fff; background:#94A3B8; box-shadow:0 0 0 2px #E2E8F0;
146
  }
147
  .cw-item.done::before{ background:var(--green) }
148
  .cw-item.current::before{ background:var(--blue) }
149
-
150
- /* Title / meta / desc */
151
  .cw-title{ margin:0 0 4px; font-weight:800 }
152
  .cw-meta{ color:#64748B; font-size:.85rem; margin-bottom:4px }
153
  .cw-desc{ color:#334155; font-size:.95rem }
154
-
155
- /* Horizontal mode for narrow screens */
156
  @media (max-width: 1100px){
157
  .cw-timeline{ padding-left:0; }
158
  .cw-timeline::before{ display:none }
159
- .cw-row{
160
- display:flex; flex-wrap:wrap; gap:12px; align-items:stretch
161
- }
162
  .cw-item{ flex:1 1 calc(50% - 12px); transform:translateY(8px) }
163
- .cw-item::before{ left:12px } /* put dot inside card */
164
- }
165
- @media (max-width: 700px){
166
- .cw-item{ flex:1 1 100% }
167
  }
 
168
 
169
- /* Animations */
170
  @keyframes aiTitle{0%{background-position:0 0}50%{background-position:100% 0}100%{background-position:0 0}}
171
- @keyframes cwFadeIn{ to{ opacity:1; transform:none } }
172
  @keyframes pulse{0%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,.55)}
173
  70%{transform:scale(1);box-shadow:0 0 0 10px rgba(54,186,1,0)}
174
  100%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,0)}}
 
 
 
 
175
  """
176
 
177
  # =============================================================================
@@ -394,13 +442,9 @@ def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
394
 
395
 
396
  # =============================================================================
397
- # Chris Wright Timeline Markup (HTML only)
398
  # =============================================================================
399
  def render_cw_timeline(events: List[Dict[str, Any]]) -> str:
400
- """
401
- Vertical sticky rail (desktop). Auto-switches to responsive row on narrow viewports.
402
- Uses classes: cw-timeline, cw-item, cw-title, cw-meta, cw-desc, state via .done/.current
403
- """
404
  items = []
405
  for ev in events:
406
  cls = ev.get("status", "done")
@@ -414,9 +458,8 @@ def render_cw_timeline(events: List[Dict[str, Any]]) -> str:
414
  <div class="cw-title">{title}</div>
415
  <div class="cw-meta">Duration: {dur_txt}</div>
416
  <div class="cw-desc">{desc}</div>
417
- </li>
418
- ''')
419
- # Wrap in list + responsive row wrapper for small screens
420
  return '<ul class="cw-timeline cw-row">' + "".join(items) + '</ul>'
421
 
422
 
@@ -496,6 +539,112 @@ def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
496
  return events, shift_assignment_results, reasoning_rows
497
 
498
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  # =============================================================================
500
  # UI
501
  # =============================================================================
@@ -509,7 +658,7 @@ def main() -> None:
509
  <div class="hero" dir="auto">
510
  <img src="https://www.healthmatrixcorp.com/web/image/website/1/logo/Health%20Matrix?unique=956ad7b" alt="Health Matrix" />
511
  <h1>AI Command Center – Smart Staffing & Actions</h1>
512
- <p>Desktop-first view with a sticky left timeline (Chris Wright-style) that shows what the agent is doing, step by step.</p>
513
  </div>
514
  """, unsafe_allow_html=True
515
  )
@@ -539,7 +688,7 @@ def main() -> None:
539
  """, unsafe_allow_html=True
540
  )
541
 
542
- # Demo input (unchanged)
543
  employee_ids_default = [850, 825]
544
  shift_data = """ShiftID,Department,RoleRequired,Specialty,MandatoryTraining,ShiftType,ShiftDuration,WardDepartment,LanguageRequirement,ShiftTime
545
  S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,2025-06-04 07:00"""
@@ -590,7 +739,7 @@ S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,202
590
  st.markdown(render_cw_timeline(events), unsafe_allow_html=True)
591
  st.markdown('</div>', unsafe_allow_html=True)
592
 
593
- # RIGHT: Overview + KPIs + Tables
594
  with right:
595
  total_steps = len(events)
596
  total_ms = sum(int(e.get("dur_ms", 0) or 0) for e in events)
@@ -609,34 +758,27 @@ S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,202
609
  )
610
  st.markdown('</div>', unsafe_allow_html=True)
611
 
612
- st.markdown('<div class="stat-row">', unsafe_allow_html=True)
613
- st.markdown(
614
- f'<div class="stat-card"><div class="stat-icon">✅</div><div class="stat-label">Assigned</div><div class="stat-value">{assigned}</div></div>'
615
- f'<div class="stat-card"><div class="stat-icon">📬</div><div class="stat-label">Notify</div><div class="stat-value">{notified}</div></div>'
616
- f'<div class="stat-card"><div class="stat-icon">⚠️</div><div class="stat-label">Skipped</div><div class="stat-value">{skipped}</div></div>',
617
- unsafe_allow_html=True
618
- )
619
- st.markdown('</div>', unsafe_allow_html=True)
620
 
621
- st.markdown("### 📊 Results")
622
- with st.expander("Shift Fulfillment Summary", expanded=True):
 
 
 
 
 
623
  if shift_assignment_results:
624
  st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
625
  st.dataframe(pd.DataFrame(shift_assignment_results, columns=["Employee", "ShiftID", "Status"]),
626
  use_container_width=True)
627
  st.markdown('</div>', unsafe_allow_html=True)
628
- else:
629
- st.info("No assignments to display.")
630
-
631
- with st.expander("Reasoning Behind Selections", expanded=False):
632
  if reasoning_rows:
633
  st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
634
  st.dataframe(pd.DataFrame(reasoning_rows), use_container_width=True)
635
  st.markdown('</div>', unsafe_allow_html=True)
636
- else:
637
- st.info("No reasoning data available.")
638
  else:
639
- st.info("Click Start AI Agent” to see the sticky timeline and results.")
640
 
641
  # Footer
642
  st.markdown(
 
1
  """
2
+ Health Matrix – AI Command Center (Desktop Wide + AI Cards)
3
+ - Sticky Left Timeline (Chris Wright–style, animated)
4
+ - KPI: Animated gradient-border cards
5
+ - Employee Results: Glass + Aurora cards (per-employee) instead of table
6
+ - Full desktop width, responsive, WCAG-friendly
7
+ - Logic/APIs unchanged (front-end only)
8
 
9
  Usage: streamlit run streamlit_app.py
10
  """
 
18
  import requests
19
  import streamlit as st
20
 
21
+ # Optional: OpenAI for GPT decisions (fails gracefully if missing)
22
  try:
23
  import openai # type: ignore
24
  HAS_OPENAI = True
 
28
 
29
 
30
  # =============================================================================
31
+ # Embedded CSS (Desktop-first, AI look)
32
  # =============================================================================
33
  _EMBEDDED_CSS = """
34
  :root{
 
38
  }
39
 
40
  /* ==== Full-width page on desktop ==== */
41
+ .main .block-container{ max-width: 1600px !important; padding-left: 16px; padding-right: 16px }
42
 
43
  /* Base */
44
  html,body{
 
104
  .overview .chip{ font-size:.9rem; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:#fff }
105
  .overview .chip b{ color:#0f172a }
106
 
107
+ /* ================= KPI: animated gradient-border cards ================= */
108
+ .kpi-grid{ display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; margin:12px 0 }
109
+ .kpi-card{
110
+ position:relative; border-radius:16px; padding:16px; text-align:center;
111
+ border:1px solid transparent;
112
+ background:
113
+ linear-gradient(#ffffff,#ffffff) padding-box,
114
+ linear-gradient(120deg, var(--green), var(--blue), var(--green)) border-box;
115
+ background-size: 300% 300%;
116
+ animation: borderShift 8s ease infinite;
117
+ box-shadow:0 10px 24px rgba(0,0,0,.06);
118
  }
119
+ .kpi-card .ico{ font-size:22px; margin-bottom:6px }
120
+ .kpi-card .lbl{ color:#334155 }
121
+ .kpi-card .val{ font-size:1.8rem; font-weight:900; line-height:1 }
122
+
123
+ /* ================= Employee (Result) Cards – Glass + Aurora ================= */
124
+ .emp-grid{
125
+ display:grid; gap:14px; margin-top:12px;
126
+ grid-template-columns: repeat(12, 1fr);
127
+ }
128
+ .emp-card{
129
+ grid-column: span 4; /* 3 per row desktop */
130
+ position:relative; overflow:hidden;
131
+ background: rgba(255,255,255,.75);
132
+ border:1px solid #E5E7EB; border-radius:16px; backdrop-filter: blur(10px);
133
+ box-shadow:0 10px 26px rgba(0,0,0,.08);
134
+ padding:14px 14px 12px; transform: translateY(6px); opacity:0;
135
+ animation: cardIn .35s ease-out forwards;
136
+ }
137
+ .emp-card:nth-child(2){ animation-delay:.03s }
138
+ .emp-card:nth-child(3){ animation-delay:.06s }
139
+ .emp-card:nth-child(4){ animation-delay:.09s }
140
+ .emp-card::before{ /* Aurora glow */
141
+ content:""; position:absolute; inset:-30% -10% auto -10%; height:60%;
142
+ background:
143
+ radial-gradient(600px 280px at 20% 30%, rgba(54,186,1,.25), transparent 60%),
144
+ radial-gradient(520px 220px at 80% 20%, rgba(0,76,151,.22), transparent 60%);
145
+ filter: blur(28px); opacity:.8; z-index:0; pointer-events:none;
146
+ animation: aurora 18s linear infinite;
147
+ }
148
+ .emp-head, .emp-body, .emp-foot{ position:relative; z-index:1 }
149
+ .emp-head{ display:flex; align-items:center; gap:10px }
150
+ .emp-avatar{
151
+ width:42px; height:42px; border-radius:999px; background:#F1F5F9;
152
+ display:flex; align-items:center; justify-content:center; font-weight:800; color:#0f172a
153
+ }
154
+ .emp-name{ font-weight:900 }
155
+ .emp-chip{ font-size:.8rem; padding:4px 8px; border-radius:999px; border:1px solid #E5E7EB; background:#fff; margin-left:auto }
156
+ .emp-chip.ok{ background:#E8F7ED; border-color:#B6E2C4 }
157
+ .emp-chip.warn{ background:#FFF7ED; border-color:#FED7AA }
158
+ .emp-chip.info{ background:#EFF6FF; border-color:#BFDBFE }
159
+ .emp-chip.fail{ background:#FEF2F2; border-color:#FECACA }
160
+
161
+ .emp-line{ height:1px; background:#E5E7EB; margin:8px 0 }
162
+
163
+ .emp-body{ display:grid; grid-template-columns: 1fr 1fr; gap:8px }
164
+ .emp-badge{
165
+ display:flex; gap:6px; align-items:center; font-size:.92rem; color:#334155;
166
+ background:#fff; border:1px solid #E5E7EB; border-radius:12px; padding:8px 10px
167
+ }
168
+ .emp-badge.ok{ border-color:#BBF7D0; background:#F0FDF4 }
169
+ .emp-badge.fail{ border-color:#FECACA; background:#FEF2F2 }
170
+ .emp-badge .dot{ width:8px; height:8px; border-radius:50% }
171
+ .emp-badge.ok .dot{ background:var(--ok) }
172
+ .emp-badge.fail .dot{ background:var(--fail) }
173
+
174
+ .emp-foot{ display:flex; flex-wrap:wrap; gap:6px; margin-top:8px }
175
+ .emp-tag{ font-size:.8rem; padding:6px 10px; border-radius:999px; border:1px solid #E5E7EB; background:#fff }
176
+ .emp-meta{ margin-left:auto; color:#64748B; font-size:.85rem }
177
+
178
+ @media (max-width: 1320px){ .emp-card{ grid-column: span 6 } } /* 2 per row */
179
+ @media (max-width: 820px){ .emp-card{ grid-column: span 12 } .emp-body{ grid-template-columns: 1fr } }
180
 
181
  /* ======================= Chris Wright Timeline (adapted) ======================= */
 
182
  .cw-rail{ position:sticky; top:84px } /* pinned under Streamlit header */
183
  @media (max-width: 1100px){ .cw-rail{ position:static } } /* not sticky on small screens */
184
 
185
+ .cw-timeline{ position:relative; margin:0; padding:0 0 0 28px; list-style:none; }
186
+ .cw-timeline::before{ content:""; position:absolute; left:11px; top:0; bottom:0; width:2px; background:#E2E8F0; }
 
 
 
 
 
 
 
187
  .cw-item{
188
  position:relative; margin:0 0 14px; background:#fff; border:1px solid var(--line);
189
  border-radius:14px; box-shadow:0 6px 14px rgba(0,0,0,.06);
190
+ padding:10px 12px; opacity:0; transform:translateX(-10px); animation:cwFadeIn .45s ease-out forwards;
 
191
  }
192
  .cw-item:nth-child(2){ animation-delay:.05s }
193
  .cw-item:nth-child(3){ animation-delay:.1s }
194
  .cw-item:nth-child(4){ animation-delay:.15s }
195
  .cw-item:nth-child(5){ animation-delay:.2s }
 
 
196
  .cw-item::before{
197
  content:""; position:absolute; left:-17px; top:14px; width:12px; height:12px;
198
  border-radius:50%; border:2px solid #fff; background:#94A3B8; box-shadow:0 0 0 2px #E2E8F0;
199
  }
200
  .cw-item.done::before{ background:var(--green) }
201
  .cw-item.current::before{ background:var(--blue) }
 
 
202
  .cw-title{ margin:0 0 4px; font-weight:800 }
203
  .cw-meta{ color:#64748B; font-size:.85rem; margin-bottom:4px }
204
  .cw-desc{ color:#334155; font-size:.95rem }
 
 
205
  @media (max-width: 1100px){
206
  .cw-timeline{ padding-left:0; }
207
  .cw-timeline::before{ display:none }
208
+ .cw-row{ display:flex; flex-wrap:wrap; gap:12px; align-items:stretch }
 
 
209
  .cw-item{ flex:1 1 calc(50% - 12px); transform:translateY(8px) }
210
+ .cw-item::before{ left:12px }
 
 
 
211
  }
212
+ @media (max-width: 700px){ .cw-item{ flex:1 1 100% } }
213
 
214
+ /* ===== Animations ===== */
215
  @keyframes aiTitle{0%{background-position:0 0}50%{background-position:100% 0}100%{background-position:0 0}}
 
216
  @keyframes pulse{0%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,.55)}
217
  70%{transform:scale(1);box-shadow:0 0 0 10px rgba(54,186,1,0)}
218
  100%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,0)}}
219
+ @keyframes borderShift{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
220
+ @keyframes cardIn{ to{ opacity:1; transform:none } }
221
+ @keyframes aurora{ 0%{transform:translateX(-10%)} 50%{transform:translateX(10%)} 100%{transform:translateX(-10%)} }
222
+ @keyframes cwFadeIn{ to{ opacity:1; transform:none } }
223
  """
224
 
225
  # =============================================================================
 
442
 
443
 
444
  # =============================================================================
445
+ # Timeline: Chris Wright (HTML builder)
446
  # =============================================================================
447
  def render_cw_timeline(events: List[Dict[str, Any]]) -> str:
 
 
 
 
448
  items = []
449
  for ev in events:
450
  cls = ev.get("status", "done")
 
458
  <div class="cw-title">{title}</div>
459
  <div class="cw-meta">Duration: {dur_txt}</div>
460
  <div class="cw-desc">{desc}</div>
461
+ </li>'''
462
+ )
 
463
  return '<ul class="cw-timeline cw-row">' + "".join(items) + '</ul>'
464
 
465
 
 
539
  return events, shift_assignment_results, reasoning_rows
540
 
541
 
542
+ # =============================================================================
543
+ # Helpers: KPI & Employee Cards (HTML)
544
+ # =============================================================================
545
+ def render_kpis(assigned:int, notified:int, skipped:int) -> str:
546
+ return f"""
547
+ <div class="kpi-grid" role="group" aria-label="KPI">
548
+ <div class="kpi-card"><div class="ico">✅</div><div class="lbl">Assigned</div><div class="val">{assigned}</div></div>
549
+ <div class="kpi-card"><div class="ico">📬</div><div class="lbl">Notify</div><div class="val">{notified}</div></div>
550
+ <div class="kpi-card"><div class="ico">⚠️</div><div class="lbl">Skipped</div><div class="val">{skipped}</div></div>
551
+ </div>
552
+ """
553
+
554
+ def render_employee_cards(shift_results: List[tuple], reasoning_rows: List[Dict[str,Any]]) -> str:
555
+ """
556
+ Builds a grid of AI-styled glass cards per employee.
557
+ shift_results: list[(Employee, ShiftID, Status)] from agent.
558
+ reasoning_rows: list of dicts with keys Employee, ShiftID, Eligible, Reasoning, Certifications.
559
+ """
560
+ # Build maps
561
+ action_by_emp: Dict[str, str] = {}
562
+ shift_by_emp: Dict[str, str] = {}
563
+ for emp, sid, status in shift_results:
564
+ if emp and not emp.startswith("❌"):
565
+ action_by_emp[emp] = status
566
+ shift_by_emp[emp] = str(sid)
567
+
568
+ # Aggregate reasoning per employee (first row per employee is enough here)
569
+ seen = set()
570
+ employees: List[Dict[str,Any]] = []
571
+ for r in reasoning_rows:
572
+ emp = r.get("Employee","").strip()
573
+ if not emp or emp in seen:
574
+ continue
575
+ seen.add(emp)
576
+ employees.append({
577
+ "name": emp,
578
+ "shift": r.get("ShiftID",""),
579
+ "eligible": r.get("Eligible",""),
580
+ "reasoning": r.get("Reasoning",""),
581
+ "certs": r.get("Certifications","")
582
+ })
583
+
584
+ if not employees:
585
+ return '<div class="table-wrap"><div style="padding:10px;color:#64748B">No employee details available.</div></div>'
586
+
587
+ # Build cards
588
+ def chip_class(txt:str)->str:
589
+ t = (txt or "").lower()
590
+ if "assigned" in t: return "ok"
591
+ if "notify" in t: return "info"
592
+ if "skip" in t: return "warn"
593
+ if "eligible" in t and "✅" in (txt or ""): return "ok"
594
+ if "eligible" in t and "❌" in (txt or ""): return "fail"
595
+ return "info"
596
+
597
+ def name_initials(n:str)->str:
598
+ parts = [p for p in n.split(" ") if p.strip()]
599
+ if not parts: return "?"
600
+ if len(parts)==1: return parts[0][:2].upper()
601
+ return (parts[0][0]+parts[-1][0]).upper()
602
+
603
+ cards = []
604
+ for emp in employees:
605
+ name = emp["name"]
606
+ action = action_by_emp.get(name)
607
+ if not action:
608
+ # derive from eligibility
609
+ action = "✅ Eligible" if emp["eligible"].startswith("✅") else "❌ Not Eligible"
610
+ chip = action
611
+ c_class = chip_class(chip)
612
+ # break reasoning into tokens
613
+ checks = [x.strip() for x in str(emp["reasoning"]).split("|") if x.strip()]
614
+ check_html = []
615
+ for ch in checks:
616
+ ok = "✅" in ch
617
+ label = ch.replace("✅","").replace("❌","").strip()
618
+ check_html.append(f'<div class="emp-badge {"ok" if ok else "fail"}"><span class="dot"></span><span>{label}</span></div>')
619
+ certs = emp["certs"] or "—"
620
+ shift = shift_by_emp.get(name, emp["shift"] or "—")
621
+ cards.append(f"""
622
+ <div class="emp-card" role="group" aria-label="{name}">
623
+ <div class="emp-head">
624
+ <div class="emp-avatar">{name_initials(name)}</div>
625
+ <div>
626
+ <div class="emp-name">{name}</div>
627
+ <div style="color:#64748B;font-size:.85rem">Shift: {shift}</div>
628
+ </div>
629
+ <div class="emp-chip {c_class}" title="Decision/Status">{chip}</div>
630
+ </div>
631
+
632
+ <div class="emp-line"></div>
633
+
634
+ <div class="emp-body">
635
+ {''.join(check_html) if check_html else '<div class="emp-badge info"><span class="dot" style="background:#60a5fa"></span><span>No reasoning available</span></div>'}
636
+ </div>
637
+
638
+ <div class="emp-foot">
639
+ <div class="emp-tag">🪪 Certifications: {certs}</div>
640
+ <div class="emp-meta">Updated just now</div>
641
+ </div>
642
+ </div>
643
+ """)
644
+
645
+ return '<div class="emp-grid">' + "".join(cards) + '</div>'
646
+
647
+
648
  # =============================================================================
649
  # UI
650
  # =============================================================================
 
658
  <div class="hero" dir="auto">
659
  <img src="https://www.healthmatrixcorp.com/web/image/website/1/logo/Health%20Matrix?unique=956ad7b" alt="Health Matrix" />
660
  <h1>AI Command Center – Smart Staffing & Actions</h1>
661
+ <p>Desktop-first view. Sticky left runline + AI-styled KPI & employee cards. No changes to logic purely visual.</p>
662
  </div>
663
  """, unsafe_allow_html=True
664
  )
 
688
  """, unsafe_allow_html=True
689
  )
690
 
691
+ # Demo input (unchanged behavior)
692
  employee_ids_default = [850, 825]
693
  shift_data = """ShiftID,Department,RoleRequired,Specialty,MandatoryTraining,ShiftType,ShiftDuration,WardDepartment,LanguageRequirement,ShiftTime
694
  S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,2025-06-04 07:00"""
 
739
  st.markdown(render_cw_timeline(events), unsafe_allow_html=True)
740
  st.markdown('</div>', unsafe_allow_html=True)
741
 
742
+ # RIGHT: Overview + KPIs + Employee Cards
743
  with right:
744
  total_steps = len(events)
745
  total_ms = sum(int(e.get("dur_ms", 0) or 0) for e in events)
 
758
  )
759
  st.markdown('</div>', unsafe_allow_html=True)
760
 
761
+ st.markdown(render_kpis(assigned, notified, skipped), unsafe_allow_html=True)
 
 
 
 
 
 
 
762
 
763
+ # ====== Employee glass cards (instead of a plain table) ======
764
+ st.markdown("### 👥 Employee Results")
765
+ emp_cards_html = render_employee_cards(shift_assignment_results, reasoning_rows)
766
+ st.markdown(emp_cards_html, unsafe_allow_html=True)
767
+
768
+ # Optional: raw tables (hidden by default) for auditing/export
769
+ with st.expander("Raw Summary (optional)", expanded=False):
770
  if shift_assignment_results:
771
  st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
772
  st.dataframe(pd.DataFrame(shift_assignment_results, columns=["Employee", "ShiftID", "Status"]),
773
  use_container_width=True)
774
  st.markdown('</div>', unsafe_allow_html=True)
 
 
 
 
775
  if reasoning_rows:
776
  st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
777
  st.dataframe(pd.DataFrame(reasoning_rows), use_container_width=True)
778
  st.markdown('</div>', unsafe_allow_html=True)
779
+
 
780
  else:
781
+ st.info("Click “▶️ Start AI Agent” to see the sticky timeline and AI-styled results.")
782
 
783
  # Footer
784
  st.markdown(