Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +142 -101
src/streamlit_app.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
| 1 |
"""
|
| 2 |
-
Health Matrix – AI Command Center (Desktop Wide + AI Cards + Showcase Mode)
|
| 3 |
- Sticky Left Timeline (Chris Wright–style, animated)
|
| 4 |
- KPI: Animated gradient-border cards
|
| 5 |
-
- Employee Results:
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
- Full desktop width, responsive, WCAG-friendly
|
| 9 |
-
-
|
| 10 |
|
| 11 |
Usage: streamlit run streamlit_app.py
|
| 12 |
"""
|
|
@@ -37,6 +39,9 @@ _EMBEDDED_CSS = """
|
|
| 37 |
--bg:#FFFFFF; --soft:#F8FAFC; --line:#E5E7EB;
|
| 38 |
--t1:#0F172A; --t2:#1F2937; --muted:#64748B;
|
| 39 |
--blue:#004C97; --green:#36BA01; --ok:#16a34a; --warn:#D97706; --fail:#DC2626;
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
/* ==== Full-width page on desktop ==== */
|
|
@@ -122,35 +127,27 @@ p{ color:var(--t2) }
|
|
| 122 |
.kpi-card .lbl{ color:#334155 }
|
| 123 |
.kpi-card .val{ font-size:1.8rem; font-weight:900; line-height:1 }
|
| 124 |
|
| 125 |
-
/* ================= Employee
|
| 126 |
-
.emp-grid{
|
| 127 |
-
display:grid; gap:14px; margin-top:12px;
|
| 128 |
-
grid-template-columns: repeat(12, 1fr);
|
| 129 |
-
}
|
| 130 |
.emp-card{
|
| 131 |
-
grid-column: span 4; /* 3 per row desktop */
|
| 132 |
position:relative; overflow:hidden;
|
| 133 |
-
background: rgba(255,255,255,.
|
| 134 |
border:1px solid #E5E7EB; border-radius:16px; backdrop-filter: blur(10px);
|
| 135 |
box-shadow:0 10px 26px rgba(0,0,0,.08);
|
| 136 |
-
padding:
|
| 137 |
animation: cardIn .35s ease-out forwards;
|
| 138 |
}
|
| 139 |
-
.emp-card:nth-child(2){ animation-delay:.03s }
|
| 140 |
-
.emp-card:nth-child(3){ animation-delay:.06s }
|
| 141 |
-
.emp-card:nth-child(4){ animation-delay:.09s }
|
| 142 |
.emp-card::before{ /* Aurora glow */
|
| 143 |
content:""; position:absolute; inset:-30% -10% auto -10%; height:60%;
|
| 144 |
background:
|
| 145 |
-
radial-gradient(600px 280px at 20% 30%, rgba(54,186,1,.
|
| 146 |
-
radial-gradient(520px 220px at 80% 20%, rgba(0,76,151,.
|
| 147 |
-
filter: blur(28px); opacity:.
|
| 148 |
animation: aurora 18s linear infinite;
|
| 149 |
}
|
| 150 |
.emp-head, .emp-body, .emp-foot{ position:relative; z-index:1 }
|
| 151 |
-
.emp-head{ display:flex; align-items:center; gap:10px }
|
| 152 |
.emp-avatar{
|
| 153 |
-
width:
|
| 154 |
display:flex; align-items:center; justify-content:center; font-weight:800; color:#0f172a
|
| 155 |
}
|
| 156 |
.emp-name{ font-weight:900 }
|
|
@@ -177,8 +174,34 @@ p{ color:var(--t2) }
|
|
| 177 |
.emp-tag{ font-size:.8rem; padding:6px 10px; border-radius:999px; border:1px solid #E5E7EB; background:#fff }
|
| 178 |
.emp-meta{ margin-left:auto; color:#64748B; font-size:.85rem }
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
/* ======================= Chris Wright Timeline (adapted) ======================= */
|
| 184 |
.cw-rail{ position:sticky; top:84px } /* pinned under Streamlit header */
|
|
@@ -414,9 +437,7 @@ def is_eligible_lenient(row: pd.Series, shift: pd.Series) -> bool:
|
|
| 414 |
]).lower()
|
| 415 |
|
| 416 |
role_ok = (row.get("JobRole","").strip().lower() == role_req) or (role_req and role_req in text_all)
|
| 417 |
-
# specialty: اكتفينا بوجود "icu" أو الكلمة الرئيسية داخل النص الكامل
|
| 418 |
spec_ok = (not specialty) or any(tok in text_all for tok in [specialty, specialty.replace(" ", ""), "icu"])
|
| 419 |
-
# training: أي ظهور لـ BLS/ALS في النص يكفي
|
| 420 |
training_ok = (not mandatory) or all(any(mtok in text_all for mtok in [m.lower(), m.lower().replace(" certified","")]) for m in mandatory)
|
| 421 |
ward_ok = (not ward) or (ward in text_all)
|
| 422 |
lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (row.get("Languages") or [])])
|
|
@@ -558,7 +579,6 @@ def run_agent(
|
|
| 558 |
t_eval = time.perf_counter()
|
| 559 |
events.append({"title":"Evaluate open shifts", "desc":"Matching employees vs. role/specialty/training/ward…", "status":"done"})
|
| 560 |
|
| 561 |
-
# eligibility function
|
| 562 |
elig_fn = is_eligible_lenient if lenient else is_eligible_strict
|
| 563 |
|
| 564 |
for _, shift in df_shifts.iterrows():
|
|
@@ -567,11 +587,9 @@ def run_agent(
|
|
| 567 |
# 3) AI decision (or showcase distribution)
|
| 568 |
t_ai = time.perf_counter()
|
| 569 |
if showcase:
|
| 570 |
-
# Rank all by score
|
| 571 |
ranked = df_employees.copy()
|
| 572 |
ranked["__score"] = ranked.apply(lambda r: _score_match(r, shift), axis=1)
|
| 573 |
ranked = ranked.sort_values("__score", ascending=False).reset_index(drop=True)
|
| 574 |
-
# pick distinct employees for assign/notify/skip
|
| 575 |
cand_assign = ranked.iloc[0] if len(ranked) > 0 else None
|
| 576 |
cand_notify = ranked.iloc[1] if len(ranked) > 1 else None
|
| 577 |
cand_skip = ranked.iloc[-1] if len(ranked) > 2 else None
|
|
@@ -611,9 +629,8 @@ def run_agent(
|
|
| 611 |
events.append({"title":"Skipped", "desc":"No eligible employees or decision skipped", "status":"done"})
|
| 612 |
shift_assignment_results.append(("❌ No eligible", shift["ShiftID"], "⚠️ Skipped"))
|
| 613 |
|
| 614 |
-
# Reasoning rows
|
| 615 |
for _, emp_row in df_employees.iterrows():
|
| 616 |
-
# عناصر التحقق
|
| 617 |
role_match = str(shift.get("RoleRequired","")).strip().lower() == emp_row.get("JobRole","").strip().lower()
|
| 618 |
spec = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 619 |
text_all = (emp_row.get("JobRole","")+" "+emp_row.get("organizationPath","")+" "+" ".join(emp_row.get("Certifications",[]))).lower()
|
|
@@ -666,13 +683,65 @@ def render_kpis(assigned:int, notified:int, skipped:int) -> str:
|
|
| 666 |
</div>
|
| 667 |
"""
|
| 668 |
|
| 669 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
"""
|
| 671 |
-
|
| 672 |
-
shift_results: list[(Employee, ShiftID, Status)] from agent.
|
| 673 |
-
reasoning_rows: list of dicts with keys Employee, ShiftID, Eligible, Reasoning, Certifications.
|
| 674 |
"""
|
| 675 |
-
#
|
| 676 |
action_by_emp: Dict[str, str] = {}
|
| 677 |
shift_by_emp: Dict[str, str] = {}
|
| 678 |
for emp, sid, status in shift_results:
|
|
@@ -680,83 +749,53 @@ def render_employee_cards(shift_results: List[tuple], reasoning_rows: List[Dict[
|
|
| 680 |
action_by_emp[str(emp)] = status
|
| 681 |
shift_by_emp[str(emp)] = str(sid)
|
| 682 |
|
| 683 |
-
#
|
| 684 |
seen = set()
|
| 685 |
employees: List[Dict[str,Any]] = []
|
| 686 |
for r in reasoning_rows:
|
| 687 |
emp = (r.get("Employee") or "").strip()
|
| 688 |
-
if not emp or emp in seen:
|
| 689 |
-
continue
|
| 690 |
seen.add(emp)
|
| 691 |
employees.append({
|
| 692 |
"name": emp,
|
| 693 |
"shift": r.get("ShiftID",""),
|
| 694 |
"eligible": r.get("Eligible",""),
|
| 695 |
"reasoning": r.get("Reasoning",""),
|
| 696 |
-
"certs": r.get("Certifications","")
|
|
|
|
| 697 |
})
|
| 698 |
|
| 699 |
if not employees:
|
| 700 |
return '<div class="table-wrap"><div style="padding:10px;color:#64748B">No employee details available.</div></div>'
|
| 701 |
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
if "eligible" in t and "❌" in (txt or ""): return "fail"
|
| 710 |
-
return "info"
|
| 711 |
-
|
| 712 |
-
def name_initials(n:str)->str:
|
| 713 |
-
n = (n or "").strip()
|
| 714 |
-
if not n: return "?"
|
| 715 |
-
parts = [p for p in n.split(" ") if p]
|
| 716 |
-
if len(parts)==1: return parts[0][:2].upper()
|
| 717 |
-
return (parts[0][0]+parts[-1][0]).upper()
|
| 718 |
-
|
| 719 |
-
cards = []
|
| 720 |
-
for emp in employees:
|
| 721 |
-
name = emp["name"]
|
| 722 |
-
action = action_by_emp.get(name)
|
| 723 |
-
if not action:
|
| 724 |
-
action = "✅ Eligible" if str(emp["eligible"]).startswith("✅") else "❌ Not Eligible"
|
| 725 |
-
chip = action
|
| 726 |
-
c_class = chip_class(chip)
|
| 727 |
-
checks = [x.strip() for x in str(emp["reasoning"] or "").split("|") if x and x.strip()]
|
| 728 |
-
check_html = []
|
| 729 |
-
for ch in checks:
|
| 730 |
-
ok = "✅" in ch or ch.endswith("✅")
|
| 731 |
-
label = ch.replace("✅","").replace("❌","").strip()
|
| 732 |
-
check_html.append(f'<div class="emp-badge {"ok" if ok else "fail"}"><span class="dot"></span><span>{label}</span></div>')
|
| 733 |
-
certs = emp["certs"] or "—"
|
| 734 |
-
shift = shift_by_emp.get(name, emp["shift"] or "—")
|
| 735 |
-
cards.append(f"""
|
| 736 |
-
<div class="emp-card" role="group" aria-label="{name}">
|
| 737 |
-
<div class="emp-head">
|
| 738 |
-
<div class="emp-avatar">{name_initials(name)}</div>
|
| 739 |
-
<div>
|
| 740 |
-
<div class="emp-name">{name}</div>
|
| 741 |
-
<div style="color:#64748B;font-size:.85rem">Shift: {shift}</div>
|
| 742 |
-
</div>
|
| 743 |
-
<div class="emp-chip {c_class}" title="Decision/Status">{chip}</div>
|
| 744 |
-
</div>
|
| 745 |
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
|
|
|
|
|
|
| 756 |
</div>
|
| 757 |
-
"""
|
| 758 |
|
| 759 |
-
return '<div class="
|
|
|
|
|
|
|
|
|
|
|
|
|
| 760 |
|
| 761 |
|
| 762 |
# =============================================================================
|
|
@@ -772,7 +811,7 @@ def main() -> None:
|
|
| 772 |
<div class="hero" dir="auto">
|
| 773 |
<img src="https://www.healthmatrixcorp.com/web/image/website/1/logo/Health%20Matrix?unique=956ad7b" alt="Health Matrix" />
|
| 774 |
<h1>AI Command Center – Smart Staffing & Actions</h1>
|
| 775 |
-
<p>Desktop-first view. Sticky left runline +
|
| 776 |
</div>
|
| 777 |
""", unsafe_allow_html=True
|
| 778 |
)
|
|
@@ -789,11 +828,12 @@ def main() -> None:
|
|
| 789 |
</div>
|
| 790 |
""", unsafe_allow_html=True)
|
| 791 |
|
| 792 |
-
# Demo controls
|
| 793 |
with st.expander("🔧 Demo / Showcase Controls", expanded=False):
|
| 794 |
show_showcase = st.checkbox("Ensure Assign + Notify + Skip (adds a demo nurse if needed)", value=True)
|
| 795 |
lenient_mode = st.checkbox("Lenient eligibility (OR) for demo", value=True)
|
| 796 |
-
st.
|
|
|
|
| 797 |
|
| 798 |
# Status
|
| 799 |
status_placeholder = st.empty()
|
|
@@ -862,7 +902,7 @@ S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,202
|
|
| 862 |
st.markdown(render_cw_timeline(events), unsafe_allow_html=True)
|
| 863 |
st.markdown('</div>', unsafe_allow_html=True)
|
| 864 |
|
| 865 |
-
# RIGHT: Overview + KPIs + Employee
|
| 866 |
with right:
|
| 867 |
total_steps = len(events)
|
| 868 |
total_ms = sum(int(e.get("dur_ms", 0) or 0) for e in events)
|
|
@@ -883,10 +923,11 @@ S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,202
|
|
| 883 |
|
| 884 |
st.markdown(render_kpis(assigned, notified, skipped), unsafe_allow_html=True)
|
| 885 |
|
| 886 |
-
# ====== Employee
|
| 887 |
st.markdown("### 👥 Employee Results")
|
| 888 |
-
|
| 889 |
-
|
|
|
|
| 890 |
|
| 891 |
# Optional: raw tables (hidden by default) for auditing/export
|
| 892 |
with st.expander("Raw Summary (optional)", expanded=False):
|
|
|
|
| 1 |
"""
|
| 2 |
+
Health Matrix – AI Command Center (Desktop Wide + AI Cards + Showcase Mode + Kanban Board)
|
| 3 |
- Sticky Left Timeline (Chris Wright–style, animated)
|
| 4 |
- KPI: Animated gradient-border cards
|
| 5 |
+
- Employee Results:
|
| 6 |
+
* Board View (Kanban: Assigned / Notify / Skipped) — واضح ومناسب للأعداد الكبيرة
|
| 7 |
+
* Grid View (النسخة السابقة) — خيار إضافي
|
| 8 |
+
- Showcase Mode: يضمن (Assign / Notify / Skip) لعرض قدرات الوكيل + يضيف موظف ثالث تجريبي (اختياري)
|
| 9 |
+
- Lenient Eligibility (OR) خيار للعرض
|
| 10 |
- Full desktop width, responsive, WCAG-friendly
|
| 11 |
+
- لا تغييرات على المنطق/الـAPIs عند إيقاف وضع العرض
|
| 12 |
|
| 13 |
Usage: streamlit run streamlit_app.py
|
| 14 |
"""
|
|
|
|
| 39 |
--bg:#FFFFFF; --soft:#F8FAFC; --line:#E5E7EB;
|
| 40 |
--t1:#0F172A; --t2:#1F2937; --muted:#64748B;
|
| 41 |
--blue:#004C97; --green:#36BA01; --ok:#16a34a; --warn:#D97706; --fail:#DC2626;
|
| 42 |
+
--tint-assign:#E8F7ED; --tint-assign-b:#B6E2C4;
|
| 43 |
+
--tint-notify:#EFF6FF; --tint-notify-b:#BFDBFE;
|
| 44 |
+
--tint-skip:#FFF7ED; --tint-skip-b:#FED7AA;
|
| 45 |
}
|
| 46 |
|
| 47 |
/* ==== Full-width page on desktop ==== */
|
|
|
|
| 127 |
.kpi-card .lbl{ color:#334155 }
|
| 128 |
.kpi-card .val{ font-size:1.8rem; font-weight:900; line-height:1 }
|
| 129 |
|
| 130 |
+
/* ================= Employee Cards – base ================= */
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
.emp-card{
|
|
|
|
| 132 |
position:relative; overflow:hidden;
|
| 133 |
+
background: rgba(255,255,255,.9);
|
| 134 |
border:1px solid #E5E7EB; border-radius:16px; backdrop-filter: blur(10px);
|
| 135 |
box-shadow:0 10px 26px rgba(0,0,0,.08);
|
| 136 |
+
padding:12px; transform: translateY(6px); opacity:0;
|
| 137 |
animation: cardIn .35s ease-out forwards;
|
| 138 |
}
|
|
|
|
|
|
|
|
|
|
| 139 |
.emp-card::before{ /* Aurora glow */
|
| 140 |
content:""; position:absolute; inset:-30% -10% auto -10%; height:60%;
|
| 141 |
background:
|
| 142 |
+
radial-gradient(600px 280px at 20% 30%, rgba(54,186,1,.18), transparent 60%),
|
| 143 |
+
radial-gradient(520px 220px at 80% 20%, rgba(0,76,151,.16), transparent 60%);
|
| 144 |
+
filter: blur(28px); opacity:.7; z-index:0; pointer-events:none;
|
| 145 |
animation: aurora 18s linear infinite;
|
| 146 |
}
|
| 147 |
.emp-head, .emp-body, .emp-foot{ position:relative; z-index:1 }
|
| 148 |
+
.emp-head{ display:flex; align-items:center; gap:10px; padding:6px 8px; border-radius:12px }
|
| 149 |
.emp-avatar{
|
| 150 |
+
width:40px; height:40px; border-radius:999px; background:#F1F5F9;
|
| 151 |
display:flex; align-items:center; justify-content:center; font-weight:800; color:#0f172a
|
| 152 |
}
|
| 153 |
.emp-name{ font-weight:900 }
|
|
|
|
| 174 |
.emp-tag{ font-size:.8rem; padding:6px 10px; border-radius:999px; border:1px solid #E5E7EB; background:#fff }
|
| 175 |
.emp-meta{ margin-left:auto; color:#64748B; font-size:.85rem }
|
| 176 |
|
| 177 |
+
/* ====== GRID mode (old view, tidy) ====== */
|
| 178 |
+
.emp-grid{
|
| 179 |
+
display:grid; gap:14px; margin-top:12px;
|
| 180 |
+
grid-template-columns: repeat(12, 1fr);
|
| 181 |
+
}
|
| 182 |
+
.emp-grid .emp-card{ grid-column: span 4 } /* 3 per row desktop */
|
| 183 |
+
@media (max-width: 1320px){ .emp-grid .emp-card{ grid-column: span 6 } } /* 2 per row */
|
| 184 |
+
@media (max-width: 820px){ .emp-grid .emp-card{ grid-column: span 12 } .emp-body{ grid-template-columns: 1fr } }
|
| 185 |
+
|
| 186 |
+
/* ====== BOARD (Kanban) ====== */
|
| 187 |
+
.board{ display:grid; grid-template-columns:repeat(3, 1fr); gap:14px; margin-top:10px }
|
| 188 |
+
.lane{
|
| 189 |
+
background:#fff; border:1px solid var(--line); border-radius:16px;
|
| 190 |
+
box-shadow:0 6px 16px rgba(0,0,0,.06); padding:12px
|
| 191 |
+
}
|
| 192 |
+
.lane-header{
|
| 193 |
+
display:flex; align-items:center; gap:8px; margin-bottom:10px;
|
| 194 |
+
font-weight:900; font-size:1.05rem;
|
| 195 |
+
}
|
| 196 |
+
.lane-header .count{ margin-left:auto; font-size:.9rem; color:#64748B; background:var(--soft); padding:4px 10px; border-radius:999px; border:1px solid var(--line) }
|
| 197 |
+
|
| 198 |
+
.lane-assign{ background:var(--tint-assign) }
|
| 199 |
+
.lane-notify{ background:var(--tint-notify) }
|
| 200 |
+
.lane-skip{ background:var(--tint-skip) }
|
| 201 |
+
|
| 202 |
+
.emp-card.status-assign .emp-head{ background:var(--tint-assign); border:1px solid var(--tint-assign-b) }
|
| 203 |
+
.emp-card.status-notify .emp-head{ background:var(--tint-notify); border:1px solid var(--tint-notify-b) }
|
| 204 |
+
.emp-card.status-skip .emp-head{ background:var(--tint-skip); border:1px solid var(--tint-skip-b) }
|
| 205 |
|
| 206 |
/* ======================= Chris Wright Timeline (adapted) ======================= */
|
| 207 |
.cw-rail{ position:sticky; top:84px } /* pinned under Streamlit header */
|
|
|
|
| 437 |
]).lower()
|
| 438 |
|
| 439 |
role_ok = (row.get("JobRole","").strip().lower() == role_req) or (role_req and role_req in text_all)
|
|
|
|
| 440 |
spec_ok = (not specialty) or any(tok in text_all for tok in [specialty, specialty.replace(" ", ""), "icu"])
|
|
|
|
| 441 |
training_ok = (not mandatory) or all(any(mtok in text_all for mtok in [m.lower(), m.lower().replace(" certified","")]) for m in mandatory)
|
| 442 |
ward_ok = (not ward) or (ward in text_all)
|
| 443 |
lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (row.get("Languages") or [])])
|
|
|
|
| 579 |
t_eval = time.perf_counter()
|
| 580 |
events.append({"title":"Evaluate open shifts", "desc":"Matching employees vs. role/specialty/training/ward…", "status":"done"})
|
| 581 |
|
|
|
|
| 582 |
elig_fn = is_eligible_lenient if lenient else is_eligible_strict
|
| 583 |
|
| 584 |
for _, shift in df_shifts.iterrows():
|
|
|
|
| 587 |
# 3) AI decision (or showcase distribution)
|
| 588 |
t_ai = time.perf_counter()
|
| 589 |
if showcase:
|
|
|
|
| 590 |
ranked = df_employees.copy()
|
| 591 |
ranked["__score"] = ranked.apply(lambda r: _score_match(r, shift), axis=1)
|
| 592 |
ranked = ranked.sort_values("__score", ascending=False).reset_index(drop=True)
|
|
|
|
| 593 |
cand_assign = ranked.iloc[0] if len(ranked) > 0 else None
|
| 594 |
cand_notify = ranked.iloc[1] if len(ranked) > 1 else None
|
| 595 |
cand_skip = ranked.iloc[-1] if len(ranked) > 2 else None
|
|
|
|
| 629 |
events.append({"title":"Skipped", "desc":"No eligible employees or decision skipped", "status":"done"})
|
| 630 |
shift_assignment_results.append(("❌ No eligible", shift["ShiftID"], "⚠️ Skipped"))
|
| 631 |
|
| 632 |
+
# Reasoning rows
|
| 633 |
for _, emp_row in df_employees.iterrows():
|
|
|
|
| 634 |
role_match = str(shift.get("RoleRequired","")).strip().lower() == emp_row.get("JobRole","").strip().lower()
|
| 635 |
spec = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 636 |
text_all = (emp_row.get("JobRole","")+" "+emp_row.get("organizationPath","")+" "+" ".join(emp_row.get("Certifications",[]))).lower()
|
|
|
|
| 683 |
</div>
|
| 684 |
"""
|
| 685 |
|
| 686 |
+
def _name_initials(n:str)->str:
|
| 687 |
+
n = (n or "").strip()
|
| 688 |
+
if not n: return "?"
|
| 689 |
+
parts = [p for p in n.split(" ") if p]
|
| 690 |
+
if len(parts)==1: return parts[0][:2].upper()
|
| 691 |
+
return (parts[0][0]+parts[-1][0]).upper()
|
| 692 |
+
|
| 693 |
+
def _chip_class(txt:str)->str:
|
| 694 |
+
t = (txt or "").lower()
|
| 695 |
+
if "auto-filled" in t or "assigned" in t: return "ok"
|
| 696 |
+
if "notify" in t: return "info"
|
| 697 |
+
if "skip" in t: return "warn"
|
| 698 |
+
if "eligible" in t and "✅" in (txt or ""): return "ok"
|
| 699 |
+
if "eligible" in t and "❌" in (txt or ""): return "fail"
|
| 700 |
+
return "info"
|
| 701 |
+
|
| 702 |
+
def _status_lane_for(emp_action:str, eligible_txt:str)->str:
|
| 703 |
+
"""assign|notify|skip"""
|
| 704 |
+
a = (emp_action or "").lower()
|
| 705 |
+
if "auto-filled" in a or "assign" in a: return "assign"
|
| 706 |
+
if "notify" in a: return "notify"
|
| 707 |
+
if a: return "skip"
|
| 708 |
+
# لا يوجد قرار صريح: انقل المؤهل إلى notify للتدقيق، والباقي skip
|
| 709 |
+
return "notify" if str(eligible_txt).startswith("✅") else "skip"
|
| 710 |
+
|
| 711 |
+
def _build_emp_card(name:str, shift:str, chip:str, reasoning:str, certs:str, lane:str)->str:
|
| 712 |
+
checks = [x.strip() for x in str(reasoning or "").split("|") if x and x.strip()]
|
| 713 |
+
check_html = []
|
| 714 |
+
for ch in checks:
|
| 715 |
+
ok = "✅" in ch or ch.endswith("✅")
|
| 716 |
+
label = ch.replace("✅","").replace("❌","").strip()
|
| 717 |
+
check_html.append(f'<div class="emp-badge {"ok" if ok else "fail"}"><span class="dot"></span><span>{label}</span></div>')
|
| 718 |
+
chip_class = _chip_class(chip)
|
| 719 |
+
return f"""
|
| 720 |
+
<div class="emp-card status-{lane}" role="group" aria-label="{name}">
|
| 721 |
+
<div class="emp-head">
|
| 722 |
+
<div class="emp-avatar">{_name_initials(name)}</div>
|
| 723 |
+
<div>
|
| 724 |
+
<div class="emp-name">{name}</div>
|
| 725 |
+
<div style="color:#64748B;font-size:.85rem">Shift: {shift or "—"}</div>
|
| 726 |
+
</div>
|
| 727 |
+
<div class="emp-chip {chip_class}" title="Decision/Status">{chip}</div>
|
| 728 |
+
</div>
|
| 729 |
+
<div class="emp-line"></div>
|
| 730 |
+
<div class="emp-body">
|
| 731 |
+
{''.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>'}
|
| 732 |
+
</div>
|
| 733 |
+
<div class="emp-foot">
|
| 734 |
+
<div class="emp-tag">🪪 Certifications: {certs or "—"}</div>
|
| 735 |
+
<div class="emp-meta">Updated just now</div>
|
| 736 |
+
</div>
|
| 737 |
+
</div>
|
| 738 |
+
"""
|
| 739 |
+
|
| 740 |
+
def render_employee_view(shift_results: List[tuple], reasoning_rows: List[Dict[str,Any]], mode:str="board") -> str:
|
| 741 |
"""
|
| 742 |
+
mode = 'board' (Kanban) or 'grid'
|
|
|
|
|
|
|
| 743 |
"""
|
| 744 |
+
# map actions
|
| 745 |
action_by_emp: Dict[str, str] = {}
|
| 746 |
shift_by_emp: Dict[str, str] = {}
|
| 747 |
for emp, sid, status in shift_results:
|
|
|
|
| 749 |
action_by_emp[str(emp)] = status
|
| 750 |
shift_by_emp[str(emp)] = str(sid)
|
| 751 |
|
| 752 |
+
# aggregate employees (first reasoning row per employee)
|
| 753 |
seen = set()
|
| 754 |
employees: List[Dict[str,Any]] = []
|
| 755 |
for r in reasoning_rows:
|
| 756 |
emp = (r.get("Employee") or "").strip()
|
| 757 |
+
if not emp or emp in seen: continue
|
|
|
|
| 758 |
seen.add(emp)
|
| 759 |
employees.append({
|
| 760 |
"name": emp,
|
| 761 |
"shift": r.get("ShiftID",""),
|
| 762 |
"eligible": r.get("Eligible",""),
|
| 763 |
"reasoning": r.get("Reasoning",""),
|
| 764 |
+
"certs": r.get("Certifications",""),
|
| 765 |
+
"action": action_by_emp.get(emp,"")
|
| 766 |
})
|
| 767 |
|
| 768 |
if not employees:
|
| 769 |
return '<div class="table-wrap"><div style="padding:10px;color:#64748B">No employee details available.</div></div>'
|
| 770 |
|
| 771 |
+
if mode == "grid":
|
| 772 |
+
cards = []
|
| 773 |
+
for emp in employees:
|
| 774 |
+
lane = _status_lane_for(emp["action"], emp["eligible"])
|
| 775 |
+
chip = emp["action"] or ("✅ Eligible" if str(emp["eligible"]).startswith("✅") else "❌ Not Eligible")
|
| 776 |
+
cards.append(_build_emp_card(emp["name"], emp["shift"], chip, emp["reasoning"], emp["certs"], lane))
|
| 777 |
+
return '<div class="emp-grid">' + "".join(cards) + '</div>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
|
| 779 |
+
# Board (Kanban)
|
| 780 |
+
lanes = {"assign": [], "notify": [], "skip": []}
|
| 781 |
+
for emp in employees:
|
| 782 |
+
lane = _status_lane_for(emp["action"], emp["eligible"])
|
| 783 |
+
chip = emp["action"] or ("✅ Eligible" if str(emp["eligible"]).startswith("✅") else "❌ Not Eligible")
|
| 784 |
+
lanes[lane].append(_build_emp_card(emp["name"], emp["shift"], chip, emp["reasoning"], emp["certs"], lane))
|
| 785 |
+
|
| 786 |
+
def lane_html(title:str, icon:str, klass:str, items:List[str]) -> str:
|
| 787 |
+
return f"""
|
| 788 |
+
<div class="lane lane-{klass}">
|
| 789 |
+
<div class="lane-header">{icon} {title} <span class="count">{len(items)}</span></div>
|
| 790 |
+
{''.join(items) if items else '<div style="color:#64748B;padding:10px">No employees in this lane.</div>'}
|
| 791 |
</div>
|
| 792 |
+
"""
|
| 793 |
|
| 794 |
+
return '<div class="board">' + \
|
| 795 |
+
lane_html("Assigned", "✅", "assign", lanes["assign"]) + \
|
| 796 |
+
lane_html("Notify", "📬", "notify", lanes["notify"]) + \
|
| 797 |
+
lane_html("Skipped", "⚠️", "skip", lanes["skip"]) + \
|
| 798 |
+
'</div>'
|
| 799 |
|
| 800 |
|
| 801 |
# =============================================================================
|
|
|
|
| 811 |
<div class="hero" dir="auto">
|
| 812 |
<img src="https://www.healthmatrixcorp.com/web/image/website/1/logo/Health%20Matrix?unique=956ad7b" alt="Health Matrix" />
|
| 813 |
<h1>AI Command Center – Smart Staffing & Actions</h1>
|
| 814 |
+
<p>Desktop-first view. Sticky left runline + KPI + <b>Kanban board</b> لنتائج الموظفين — واضح للأعداد الكبيرة. Showcase اختياري.</p>
|
| 815 |
</div>
|
| 816 |
""", unsafe_allow_html=True
|
| 817 |
)
|
|
|
|
| 828 |
</div>
|
| 829 |
""", unsafe_allow_html=True)
|
| 830 |
|
| 831 |
+
# Demo controls
|
| 832 |
with st.expander("🔧 Demo / Showcase Controls", expanded=False):
|
| 833 |
show_showcase = st.checkbox("Ensure Assign + Notify + Skip (adds a demo nurse if needed)", value=True)
|
| 834 |
lenient_mode = st.checkbox("Lenient eligibility (OR) for demo", value=True)
|
| 835 |
+
view_mode = st.radio("Employee results view", options=["Board (Kanban)", "Grid"], index=0, horizontal=True)
|
| 836 |
+
st.caption("عند إيقاف هذه الخيارات يعود السلوك إلى المنطق الأصلي الصارم (AND).")
|
| 837 |
|
| 838 |
# Status
|
| 839 |
status_placeholder = st.empty()
|
|
|
|
| 902 |
st.markdown(render_cw_timeline(events), unsafe_allow_html=True)
|
| 903 |
st.markdown('</div>', unsafe_allow_html=True)
|
| 904 |
|
| 905 |
+
# RIGHT: Overview + KPIs + Employee View
|
| 906 |
with right:
|
| 907 |
total_steps = len(events)
|
| 908 |
total_ms = sum(int(e.get("dur_ms", 0) or 0) for e in events)
|
|
|
|
| 923 |
|
| 924 |
st.markdown(render_kpis(assigned, notified, skipped), unsafe_allow_html=True)
|
| 925 |
|
| 926 |
+
# ====== Employee board/grid ======
|
| 927 |
st.markdown("### 👥 Employee Results")
|
| 928 |
+
mode = "board" if view_mode.startswith("Board") else "grid"
|
| 929 |
+
emp_html = render_employee_view(shift_assignment_results, reasoning_rows, mode=mode)
|
| 930 |
+
st.markdown(emp_html, unsafe_allow_html=True)
|
| 931 |
|
| 932 |
# Optional: raw tables (hidden by default) for auditing/export
|
| 933 |
with st.expander("Raw Summary (optional)", expanded=False):
|