Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +258 -269
src/streamlit_app.py
CHANGED
|
@@ -1,25 +1,18 @@
|
|
| 1 |
"""
|
| 2 |
-
Health Matrix – AI Command Center (
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
- Readable tables: sticky headers, zebra rows; horizontal scroll support
|
| 11 |
-
- Loading skeletons, friendly empty/error states
|
| 12 |
-
- RTL/LTR friendly styles
|
| 13 |
-
|
| 14 |
-
All logic, API calls, inputs/outputs are unchanged.
|
| 15 |
-
Usage: streamlit run streamlit_app.py
|
| 16 |
"""
|
| 17 |
|
| 18 |
import os
|
| 19 |
-
import json
|
| 20 |
import time
|
| 21 |
-
from typing import List, Optional, Iterable, Dict, Any
|
| 22 |
from io import StringIO
|
|
|
|
| 23 |
|
| 24 |
import pandas as pd
|
| 25 |
import requests
|
|
@@ -29,141 +22,151 @@ import streamlit as st
|
|
| 29 |
try:
|
| 30 |
import openai # type: ignore
|
| 31 |
HAS_OPENAI = True
|
| 32 |
-
except
|
| 33 |
-
openai = None
|
| 34 |
HAS_OPENAI = False
|
| 35 |
|
| 36 |
# =============================================================================
|
| 37 |
-
# Embedded
|
| 38 |
# =============================================================================
|
| 39 |
_EMBEDDED_CSS = """
|
| 40 |
:root{
|
| 41 |
--bg:#FFFFFF; --soft:#F8FAFC; --line:#E5E7EB;
|
| 42 |
-
--t1:#0F172A; --t2:#1F2937; --
|
| 43 |
-
--blue:#004C97; --green:#36BA01; --ok:#16a34a; --warn:#D97706;
|
| 44 |
}
|
| 45 |
|
| 46 |
-
/* ====
|
| 47 |
-
.main .block-container{
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
/*
|
| 57 |
[data-testid="stDataFrame"] table thead th{
|
| 58 |
position: sticky; top: 0; z-index: 2;
|
| 59 |
background:#003b70 !important; color:#fff !important;
|
| 60 |
}
|
| 61 |
-
[data-testid="stDataFrame"] table tbody tr:nth-child(odd){background
|
| 62 |
-
[data-testid="stDataFrame"] table td{padding:8px 10px}
|
| 63 |
|
| 64 |
-
/*
|
| 65 |
-
.table-wrap{
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
/* =====
|
| 68 |
-
.hero{position:relative;text-align:center;padding:
|
| 69 |
-
.hero img{width:
|
| 70 |
-
.hero h1{
|
| 71 |
-
.
|
| 72 |
background:linear-gradient(90deg,var(--t1),var(--blue),var(--t1));
|
| 73 |
-
-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;
|
| 74 |
-
background-size:200% 100%;animation:aiTitle 8s ease-in-out infinite;
|
| 75 |
-
}
|
| 76 |
-
.hero p{font-size:1rem;line-height:1.6;color:var(--t2);max-width:900px;margin:0 auto}
|
| 77 |
-
.hero::before,.hero::after{content:"";position:absolute;inset:-20%;pointer-events:none;filter:blur(42px);opacity:.18}
|
| 78 |
-
.hero::before{
|
| 79 |
-
background:
|
| 80 |
-
radial-gradient(600px 280px at 20% 30%, rgba(54,186,1,.45), transparent 60%),
|
| 81 |
-
radial-gradient(500px 240px at 80% 70%, rgba(0,76,151,.35), transparent 60%);
|
| 82 |
}
|
| 83 |
-
.hero
|
| 84 |
-
animation:slowSpin 26s linear infinite}
|
| 85 |
|
| 86 |
-
/*
|
| 87 |
-
.stButton{display:flex;justify-content:center;margin:
|
| 88 |
.stButton>button{
|
| 89 |
-
padding:
|
| 90 |
-
|
| 91 |
background:linear-gradient(120deg,var(--green),var(--blue),var(--green)) !important;
|
| 92 |
-
background-size:320% 320% !important;animation:aiGradient 8s ease-in-out infinite;
|
| 93 |
-
box-shadow:0
|
| 94 |
-
|
| 95 |
-
.stButton>button:
|
| 96 |
-
|
| 97 |
-
/*
|
| 98 |
-
.status-wrap{display:flex;justify-content:center;margin:8px 0}
|
| 99 |
-
.status-bar{
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
.
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
.
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
.card
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
.
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
.
|
| 118 |
-
.
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
.
|
| 123 |
-
.
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
.
|
| 128 |
-
|
| 129 |
-
.
|
| 130 |
-
.
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
.run-
|
| 134 |
-
.run-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
.
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
.
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.45), rgba(255,255,255,0));
|
| 146 |
-
transform:translateX(-100%);animation:shimmer 1.2s infinite
|
| 147 |
-
|
| 148 |
-
|
| 149 |
|
| 150 |
-
/*
|
| 151 |
@keyframes aiGradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
|
| 152 |
@keyframes aiTitle{0%{background-position:0 0}50%{background-position:100% 0}100%{background-position:0 0}}
|
| 153 |
-
@keyframes
|
| 154 |
-
|
| 155 |
-
|
| 156 |
@keyframes fadeIn{to{opacity:1}}
|
| 157 |
-
|
| 158 |
-
/* ================= Responsive ================= */
|
| 159 |
-
@media (max-width:768px){
|
| 160 |
-
.hero h1{font-size:1.6rem}
|
| 161 |
-
.stat-row{flex-direction:column}
|
| 162 |
-
}
|
| 163 |
"""
|
| 164 |
|
| 165 |
# =============================================================================
|
| 166 |
-
# UKG API helpers (unchanged
|
| 167 |
# =============================================================================
|
| 168 |
def _get_auth_header() -> Dict[str, str]:
|
| 169 |
app_key = os.environ.get("UKG_APP_KEY")
|
|
@@ -302,14 +305,13 @@ def fetch_employees(employee_ids: Iterable[int]) -> pd.DataFrame:
|
|
| 302 |
df = pd.DataFrame(records)
|
| 303 |
for col in ["personNumber", "organizationPath", "phoneNumber", "fullName", "Certifications"]:
|
| 304 |
if col not in df.columns: df[col] = []
|
| 305 |
-
# Optionals for matching
|
| 306 |
if "Languages" not in df.columns:
|
| 307 |
df["Languages"] = [[] for _ in range(len(df))]
|
| 308 |
df["JobRole"] = df["organizationPath"].apply(lambda p: p.split("/")[-1] if isinstance(p, str) and p else "")
|
| 309 |
return df
|
| 310 |
|
| 311 |
# =============================================================================
|
| 312 |
-
# Decision logic
|
| 313 |
# =============================================================================
|
| 314 |
def _has_any(tokens: List[str], haystack: str) -> bool:
|
| 315 |
hay = (haystack or "").lower()
|
|
@@ -320,7 +322,6 @@ def _has_all_in_list(need: List[str], have: List[str]) -> bool:
|
|
| 320 |
return all(n.strip().lower() in have_l for n in need if n and n.strip())
|
| 321 |
|
| 322 |
def is_eligible(row: pd.Series, shift: pd.Series) -> bool:
|
| 323 |
-
"""Eligibility using expanded criteria."""
|
| 324 |
role_req = str(shift.get("RoleRequired","")).strip().lower()
|
| 325 |
specialty = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 326 |
mandatory = [t.strip() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
|
|
@@ -334,7 +335,6 @@ def is_eligible(row: pd.Series, shift: pd.Series) -> bool:
|
|
| 334 |
training_ok = (not mandatory) or _has_all_in_list(mandatory, row.get("Certifications", []))
|
| 335 |
ward_ok = (not ward) or _has_any([ward], row.get("organizationPath",""))
|
| 336 |
lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (row.get("Languages") or [])])
|
| 337 |
-
|
| 338 |
return role_ok and spec_ok and training_ok and ward_ok and lang_ok
|
| 339 |
|
| 340 |
def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
|
|
@@ -374,8 +374,9 @@ def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
|
|
| 374 |
try:
|
| 375 |
resp = openai.ChatCompletion.create(
|
| 376 |
model="gpt-4",
|
| 377 |
-
messages=[
|
| 378 |
-
|
|
|
|
| 379 |
temperature=0.4, max_tokens=200,
|
| 380 |
)
|
| 381 |
import ast
|
|
@@ -384,44 +385,37 @@ def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
|
|
| 384 |
st.error(f"❌ GPT Error: {e}")
|
| 385 |
return {"action": "skip"}
|
| 386 |
|
| 387 |
-
#
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
cls = ev.get("status","done")
|
| 391 |
-
dur = ev.get("dur_ms")
|
| 392 |
-
dur_txt = f"{int(dur)} ms" if isinstance(dur,(int,float)) else "—"
|
| 393 |
-
return f"""
|
| 394 |
-
<li class="{cls}">
|
| 395 |
-
<h5>{ev.get('title','')}</h5>
|
| 396 |
-
<div class="meta"><span>Duration: {dur_txt}</span></div>
|
| 397 |
-
<div class="desc">{ev.get('desc','')}</div>
|
| 398 |
-
</li>"""
|
| 399 |
-
html = '<div class="run-wrap" dir="auto"><div class="run-legend">' \
|
| 400 |
-
'<span class="chip">● Current</span><span class="chip">● Done</span></div>' \
|
| 401 |
-
'<ul class="run">{items}</ul></div>'
|
| 402 |
-
return html.replace("{items}", "".join(li(e) for e in events))
|
| 403 |
-
|
| 404 |
-
# -------- NEW: WIDE GRID timeline (uses the full horizontal space) ----------
|
| 405 |
def render_runline_wide(events: List[Dict[str, Any]]) -> str:
|
| 406 |
-
|
| 407 |
-
|
|
|
|
| 408 |
dur = ev.get("dur_ms")
|
| 409 |
-
dur_txt = f"{int(dur)} ms" if isinstance(dur,(int,float)) else "—"
|
| 410 |
-
title = ev.get("title","")
|
| 411 |
-
desc = ev.get("desc","")
|
| 412 |
-
return
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
|
| 423 |
# =============================================================================
|
| 424 |
-
#
|
| 425 |
# =============================================================================
|
| 426 |
def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
|
| 427 |
events: List[Dict[str, Any]] = []
|
|
@@ -444,7 +438,7 @@ def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
|
|
| 444 |
|
| 445 |
# 3) AI decision
|
| 446 |
t_ai = time.perf_counter()
|
| 447 |
-
events.append({"title":"AI decision", "desc":"Select assign / notify / skip", "status":"done"})
|
| 448 |
decision = gpt_decide(shift, eligible)
|
| 449 |
ai_dur = (time.perf_counter() - t_ai) * 1000
|
| 450 |
events[-1]["dur_ms"] = max(int(ai_dur),1)
|
|
@@ -498,42 +492,36 @@ def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
|
|
| 498 |
return events, shift_assignment_results, reasoning_rows
|
| 499 |
|
| 500 |
# =============================================================================
|
| 501 |
-
#
|
| 502 |
# =============================================================================
|
| 503 |
def main() -> None:
|
| 504 |
st.set_page_config(page_title="Health Matrix AI Command Center", layout="wide")
|
| 505 |
-
os.environ.setdefault("XDG_CONFIG_HOME","/tmp"); os.environ.setdefault("HF_HOME","/tmp"); os.environ.setdefault("TRANSFORMERS_CACHE","/tmp")
|
| 506 |
st.markdown(f"<style>{_EMBEDDED_CSS}</style>", unsafe_allow_html=True)
|
| 507 |
|
| 508 |
-
#
|
| 509 |
st.markdown(
|
| 510 |
"""
|
| 511 |
<div class="hero" dir="auto">
|
| 512 |
<img src="https://www.healthmatrixcorp.com/web/image/website/1/logo/Health%20Matrix?unique=956ad7b" alt="Health Matrix" />
|
| 513 |
-
<h1
|
| 514 |
-
<p>
|
| 515 |
</div>
|
| 516 |
""", unsafe_allow_html=True
|
| 517 |
)
|
| 518 |
|
| 519 |
-
#
|
| 520 |
st.markdown("""
|
| 521 |
-
<div class="card" style="max-width:
|
| 522 |
-
<h4
|
| 523 |
-
<p style="margin:0;">
|
| 524 |
-
Health Matrix AI Agent يراقب الشفتات المفتوحة ويطابقها تلقائيًا مع الموارد المؤهّلة وفق المعايير:
|
| 525 |
-
<b>Role</b>، <b>Specialty</b>، <b>Mandatory Training</b>، <b>Shift Type/Duration</b>، <b>Ward/Department</b>، <b>Language</b>.
|
| 526 |
-
ثم يقرّر <b>Assign</b> أو <b>Notify</b> تلقائيًا ويعرض سجل التنفيذ خطوة-بخطوة.
|
| 527 |
-
</p>
|
| 528 |
<ul>
|
| 529 |
-
<li>✅ Reduce time to fill critical shifts
|
| 530 |
-
<li>📜
|
| 531 |
<li>📊 Transparent Agent Runline (steps + durations)</li>
|
| 532 |
</ul>
|
| 533 |
</div>
|
| 534 |
""", unsafe_allow_html=True)
|
| 535 |
|
| 536 |
-
#
|
| 537 |
status_placeholder = st.empty()
|
| 538 |
status_placeholder.markdown(
|
| 539 |
"""
|
|
@@ -546,19 +534,21 @@ def main() -> None:
|
|
| 546 |
""", unsafe_allow_html=True
|
| 547 |
)
|
| 548 |
|
| 549 |
-
# Demo
|
| 550 |
employee_ids_default = [850, 825]
|
| 551 |
shift_data = """ShiftID,Department,RoleRequired,Specialty,MandatoryTraining,ShiftType,ShiftDuration,WardDepartment,LanguageRequirement,ShiftTime
|
| 552 |
S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,2025-06-04 07:00"""
|
| 553 |
df_shifts_default = pd.read_csv(StringIO(shift_data))
|
| 554 |
|
| 555 |
-
#
|
| 556 |
-
|
| 557 |
-
with
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
|
|
|
|
|
|
| 562 |
<div class="status-wrap" role="status" aria-live="polite">
|
| 563 |
<div class="status-bar">
|
| 564 |
<span class="pulse-dot"></span>
|
|
@@ -566,32 +556,28 @@ S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,202
|
|
| 566 |
</div>
|
| 567 |
</div>
|
| 568 |
""", unsafe_allow_html=True
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
# Done state
|
| 593 |
-
status_placeholder.markdown(
|
| 594 |
-
"""
|
| 595 |
<div class="status-wrap" role="status" aria-live="polite">
|
| 596 |
<div class="status-bar status-done">
|
| 597 |
<span class="pulse-dot"></span>
|
|
@@ -599,62 +585,65 @@ S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,202
|
|
| 599 |
</div>
|
| 600 |
</div>
|
| 601 |
""", unsafe_allow_html=True
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
# ---------------- Agent Overview (clarify agent function) ----------------
|
| 605 |
-
total_steps = len(events)
|
| 606 |
-
total_ms = sum(int(e.get("dur_ms", 0) or 0) for e in events)
|
| 607 |
-
assigned = sum(1 for e in events if e["title"].startswith("Assigned"))
|
| 608 |
-
notified = sum(1 for e in events if e["title"].startswith("Notify"))
|
| 609 |
-
skipped = sum(1 for e in events if e["title"].startswith("Skipped"))
|
| 610 |
-
st.markdown('<div class="overview">', unsafe_allow_html=True)
|
| 611 |
-
st.markdown(
|
| 612 |
-
f'<span class="chip">🧠 <b>Agent Steps:</b> {total_steps}</span>'
|
| 613 |
-
f'<span class="chip">⏱️ <b>Total Duration:</b> {total_ms} ms</span>'
|
| 614 |
-
f'<span class="chip">✅ <b>Assigned:</b> {assigned}</span>'
|
| 615 |
-
f'<span class="chip">📬 <b>Notify:</b> {notified}</span>'
|
| 616 |
-
f'<span class="chip">⚠️ <b>Skipped:</b> {skipped}</span>',
|
| 617 |
-
unsafe_allow_html=True
|
| 618 |
-
)
|
| 619 |
-
st.markdown('</div>', unsafe_allow_html=True)
|
| 620 |
-
|
| 621 |
-
# --- Agent Runline (WIDE GRID) ---
|
| 622 |
-
st.markdown("### 🕒 Agent Runline")
|
| 623 |
-
st.markdown(render_runline_wide(events), unsafe_allow_html=True)
|
| 624 |
-
|
| 625 |
-
# --- Stat row (quick KPIs) ---
|
| 626 |
-
st.markdown('<div class="stat-row">', unsafe_allow_html=True)
|
| 627 |
-
st.markdown(f'''
|
| 628 |
-
<div class="stat-card" role="group" aria-label="Assigned"><div class="stat-icon">✅</div><div class="stat-label">Assigned</div><div class="stat-value">{assigned}</div></div>
|
| 629 |
-
<div class="stat-card" role="group" aria-label="Notify"><div class="stat-icon">📬</div><div class="stat-label">Notify</div><div class="stat-value">{notified}</div></div>
|
| 630 |
-
<div class="stat-card" role="group" aria-label="Skipped"><div class="stat-icon">⚠️</div><div class="stat-label">Skipped</div><div class="stat-value">{skipped}</div></div>
|
| 631 |
-
''', unsafe_allow_html=True)
|
| 632 |
-
st.markdown('</div>', unsafe_allow_html=True)
|
| 633 |
-
|
| 634 |
-
# --- Results ---
|
| 635 |
-
st.markdown("### 📊 Results")
|
| 636 |
-
with st.expander("Shift Fulfillment Summary", expanded=True):
|
| 637 |
-
if shift_assignment_results:
|
| 638 |
-
st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
|
| 639 |
-
st.dataframe(pd.DataFrame(shift_assignment_results, columns=["Employee", "ShiftID", "Status"]),
|
| 640 |
-
use_container_width=True)
|
| 641 |
-
st.markdown('</div>', unsafe_allow_html=True)
|
| 642 |
-
else:
|
| 643 |
-
st.markdown('<div class="empty">No assignments to display.</div>', unsafe_allow_html=True)
|
| 644 |
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
<h4>How it works</h4>
|
| 659 |
<ol style="margin:6px 0 0 18px;color:#334155">
|
| 660 |
<li>Fetch employees & certifications from UKG demo API</li>
|
|
@@ -664,13 +653,13 @@ S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,202
|
|
| 664 |
</ol>
|
| 665 |
</div>
|
| 666 |
""", unsafe_allow_html=True
|
| 667 |
-
|
| 668 |
|
| 669 |
-
#
|
| 670 |
st.markdown(
|
| 671 |
"""
|
| 672 |
<hr/>
|
| 673 |
-
<div
|
| 674 |
© 2025 Health Matrix Corp – Empowering Digital Health Transformation · <a href="mailto:info@healthmatrixcorp.com">info@healthmatrixcorp.com</a>
|
| 675 |
</div>
|
| 676 |
""", unsafe_allow_html=True
|
|
|
|
| 1 |
"""
|
| 2 |
+
Health Matrix – AI Command Center (Wide Web UI)
|
| 3 |
+
- Full-width desktop layout
|
| 4 |
+
- Horizontal (Grid) Agent Runline that uses screen width
|
| 5 |
+
- Agent Overview chips (steps / total duration / decisions)
|
| 6 |
+
- Readable data tables (sticky header, zebra, full width, horizontal scroll on narrow)
|
| 7 |
+
- No logic/API/decision changes
|
| 8 |
+
|
| 9 |
+
This is a front-end only enhancement.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
import os
|
|
|
|
| 13 |
import time
|
|
|
|
| 14 |
from io import StringIO
|
| 15 |
+
from typing import List, Optional, Iterable, Dict, Any
|
| 16 |
|
| 17 |
import pandas as pd
|
| 18 |
import requests
|
|
|
|
| 22 |
try:
|
| 23 |
import openai # type: ignore
|
| 24 |
HAS_OPENAI = True
|
| 25 |
+
except Exception:
|
| 26 |
+
openai = None
|
| 27 |
HAS_OPENAI = False
|
| 28 |
|
| 29 |
# =============================================================================
|
| 30 |
+
# Embedded CSS (Desktop-first, wide layout)
|
| 31 |
# =============================================================================
|
| 32 |
_EMBEDDED_CSS = """
|
| 33 |
:root{
|
| 34 |
--bg:#FFFFFF; --soft:#F8FAFC; --line:#E5E7EB;
|
| 35 |
+
--t1:#0F172A; --t2:#1F2937; --muted:#64748B;
|
| 36 |
+
--blue:#004C97; --green:#36BA01; --ok:#16a34a; --warn:#D97706;
|
| 37 |
}
|
| 38 |
|
| 39 |
+
/* ==== Use full width on desktop ==== */
|
| 40 |
+
.main .block-container{
|
| 41 |
+
max-width: 1600px !important;
|
| 42 |
+
padding-left: 16px; padding-right: 16px;
|
| 43 |
+
}
|
| 44 |
|
| 45 |
+
/* Base */
|
| 46 |
+
html,body{
|
| 47 |
+
margin:0; padding:0; height:100%;
|
| 48 |
+
background:var(--bg); color:var(--t1);
|
| 49 |
+
font-family:'Segoe UI','Cairo','Tajawal','Open Sans',sans-serif;
|
| 50 |
+
}
|
| 51 |
+
h1,h2,h3{ margin:0 0 8px; color:var(--t1); text-align:center }
|
| 52 |
+
p{ color:var(--t2) }
|
| 53 |
|
| 54 |
+
/* Dataframes: sticky header + zebra + compact */
|
| 55 |
[data-testid="stDataFrame"] table thead th{
|
| 56 |
position: sticky; top: 0; z-index: 2;
|
| 57 |
background:#003b70 !important; color:#fff !important;
|
| 58 |
}
|
| 59 |
+
[data-testid="stDataFrame"] table tbody tr:nth-child(odd){ background:#f9fafb }
|
| 60 |
+
[data-testid="stDataFrame"] table td{ padding:8px 10px }
|
| 61 |
|
| 62 |
+
/* Scroll wrapper (when needed) */
|
| 63 |
+
.table-wrap{
|
| 64 |
+
overflow-x:auto; border:1px solid var(--line); border-radius:12px; margin-top:6px;
|
| 65 |
+
}
|
| 66 |
|
| 67 |
+
/* ===== Hero ===== */
|
| 68 |
+
.hero{ position:relative; text-align:center; padding:48px 8px 8px }
|
| 69 |
+
.hero img{ width:170px; height:auto; margin-bottom:8px }
|
| 70 |
+
.hero h1{
|
| 71 |
+
font-size:2.1rem; font-weight:900; line-height:1.25; margin:0 0 6px;
|
| 72 |
background:linear-gradient(90deg,var(--t1),var(--blue),var(--t1));
|
| 73 |
+
-webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
|
| 74 |
+
background-size:200% 100%; animation:aiTitle 8s ease-in-out infinite;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
+
.hero p{ max-width:1050px; margin:0 auto; font-size:1.02rem }
|
|
|
|
| 77 |
|
| 78 |
+
/* CTA */
|
| 79 |
+
.stButton{ display:flex; justify-content:center; margin:10px 0 0 }
|
| 80 |
.stButton>button{
|
| 81 |
+
padding:12px 26px; font-size:1.05rem; font-weight:800; color:#fff !important;
|
| 82 |
+
border:0 !important; border-radius:14px !important; cursor:pointer;
|
| 83 |
background:linear-gradient(120deg,var(--green),var(--blue),var(--green)) !important;
|
| 84 |
+
background-size:320% 320% !important; animation:aiGradient 8s ease-in-out infinite;
|
| 85 |
+
box-shadow:0 10px 20px rgba(0,0,0,.12); transition:transform .18s ease, box-shadow .18s ease
|
| 86 |
+
}
|
| 87 |
+
.stButton>button:hover{ transform:translateY(-2px) scale(1.02); box-shadow:0 0 22px rgba(54,186,1,.26) }
|
| 88 |
+
|
| 89 |
+
/* Status Bar */
|
| 90 |
+
.status-wrap{ display:flex; justify-content:center; margin:8px 0 }
|
| 91 |
+
.status-bar{
|
| 92 |
+
display:flex; align-items:center; gap:10px; background:var(--soft); color:var(--t2);
|
| 93 |
+
border:1px solid var(--line); border-radius:12px; padding:8px 14px; font-size:.95rem
|
| 94 |
+
}
|
| 95 |
+
.pulse-dot{ width:10px; height:10px; border-radius:50%; background:var(--green);
|
| 96 |
+
box-shadow:0 0 0 0 rgba(54,186,1,.55); animation:pulse 1.4s infinite }
|
| 97 |
+
.status-done{ background:#E8F7ED; border-color:#B6E2C4 }
|
| 98 |
+
.status-done .pulse-dot{ background:var(--ok); box-shadow:none }
|
| 99 |
+
|
| 100 |
+
/* Card */
|
| 101 |
+
.card{
|
| 102 |
+
background:#fff; border:1px solid var(--line); border-radius:16px;
|
| 103 |
+
box-shadow:0 6px 16px rgba(0,0,0,.06); padding:14px; margin-bottom:12px
|
| 104 |
+
}
|
| 105 |
+
.card h4{ margin:0 0 6px }
|
| 106 |
+
.card ul{ margin:8px 0 0 18px; color:#334155; font-size:.95rem; line-height:1.6 }
|
| 107 |
+
|
| 108 |
+
/* Overview chips */
|
| 109 |
+
.overview{ display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin:4px 0 10px }
|
| 110 |
+
.overview .chip{ font-size:.9rem; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:#fff }
|
| 111 |
+
.overview .chip b{ color:#0f172a }
|
| 112 |
+
|
| 113 |
+
/* KPI */
|
| 114 |
+
.stat-row{ display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; margin-top:10px }
|
| 115 |
+
.stat-card{
|
| 116 |
+
background:#fff; padding:14px; border-radius:14px; border:1px solid var(--line);
|
| 117 |
+
box-shadow:0 6px 16px rgba(0,0,0,.06); text-align:center; transition:transform .2s ease
|
| 118 |
+
}
|
| 119 |
+
.stat-card:hover{ transform:scale(1.02) }
|
| 120 |
+
.stat-icon{ font-size:22px; margin-bottom:.2rem }
|
| 121 |
+
.stat-label{ color:#334155 }
|
| 122 |
+
.stat-value{ font-size:1.7rem; font-weight:900 }
|
| 123 |
+
|
| 124 |
+
/* ======= WIDE Runline (Grid) ======= */
|
| 125 |
+
.run-legend{ display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin:6px 0 6px }
|
| 126 |
+
.run-legend .chip{ font-size:.85rem; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:#fff }
|
| 127 |
+
.runH{
|
| 128 |
+
display:grid; gap:12px; margin:6px auto 12px; align-items:stretch;
|
| 129 |
+
grid-template-columns: repeat(12, 1fr);
|
| 130 |
+
max-width: 1520px;
|
| 131 |
+
}
|
| 132 |
+
.run-card{
|
| 133 |
+
grid-column: span 3; /* 4 cards per row on desktop */
|
| 134 |
+
background:#fff; border:1px solid var(--line); border-radius:14px; padding:12px;
|
| 135 |
+
box-shadow:0 6px 14px rgba(0,0,0,.06); position:relative; min-height:110px;
|
| 136 |
+
opacity:0; animation:fadeIn .45s forwards
|
| 137 |
+
}
|
| 138 |
+
.run-card .dot{ position:absolute; left:10px; top:12px; width:10px; height:10px; border-radius:50% }
|
| 139 |
+
.run-card .title{ margin:0 0 6px; padding-left:22px; font-weight:800 }
|
| 140 |
+
.run-card .meta{ color:#64748B; font-size:.85rem; margin-bottom:4px; padding-left:22px }
|
| 141 |
+
.run-card .desc{ color:#334155; font-size:.95rem; padding-left:22px }
|
| 142 |
+
.run-card.done .dot{ background:var(--green) }
|
| 143 |
+
.run-card.current .dot{ background:var(--blue) }
|
| 144 |
+
|
| 145 |
+
@media (max-width:1450px){ .run-card{ grid-column: span 4 } } /* 3 per row */
|
| 146 |
+
@media (max-width:1150px){ .run-card{ grid-column: span 6 } } /* 2 per row */
|
| 147 |
+
@media (max-width:760px){ .run-card{ grid-column: span 12 } } /* 1 per row */
|
| 148 |
+
|
| 149 |
+
/* Loading skeleton */
|
| 150 |
+
.skel{ position:relative; overflow:hidden; background:#E5E7EB; border-radius:12px }
|
| 151 |
+
.skel::after{
|
| 152 |
+
content:""; position:absolute; inset:0;
|
| 153 |
background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.45), rgba(255,255,255,0));
|
| 154 |
+
transform:translateX(-100%); animation:shimmer 1.2s infinite
|
| 155 |
+
}
|
| 156 |
+
.skel.h80{height:80px}.skel.h120{height:120px}
|
| 157 |
|
| 158 |
+
/* Animations */
|
| 159 |
@keyframes aiGradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
|
| 160 |
@keyframes aiTitle{0%{background-position:0 0}50%{background-position:100% 0}100%{background-position:0 0}}
|
| 161 |
+
@keyframes pulse{0%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,.55)}
|
| 162 |
+
70%{transform:scale(1);box-shadow:0 0 0 10px rgba(54,186,1,0)}
|
| 163 |
+
100%{transform:scale(.95);box-shadow:0 0 0 0 rgba(54,186,1,0)}}
|
| 164 |
@keyframes fadeIn{to{opacity:1}}
|
| 165 |
+
@keyframes shimmer{100%{transform:translateX(100%)}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
"""
|
| 167 |
|
| 168 |
# =============================================================================
|
| 169 |
+
# UKG API helpers (unchanged)
|
| 170 |
# =============================================================================
|
| 171 |
def _get_auth_header() -> Dict[str, str]:
|
| 172 |
app_key = os.environ.get("UKG_APP_KEY")
|
|
|
|
| 305 |
df = pd.DataFrame(records)
|
| 306 |
for col in ["personNumber", "organizationPath", "phoneNumber", "fullName", "Certifications"]:
|
| 307 |
if col not in df.columns: df[col] = []
|
|
|
|
| 308 |
if "Languages" not in df.columns:
|
| 309 |
df["Languages"] = [[] for _ in range(len(df))]
|
| 310 |
df["JobRole"] = df["organizationPath"].apply(lambda p: p.split("/")[-1] if isinstance(p, str) and p else "")
|
| 311 |
return df
|
| 312 |
|
| 313 |
# =============================================================================
|
| 314 |
+
# Decision logic (unchanged)
|
| 315 |
# =============================================================================
|
| 316 |
def _has_any(tokens: List[str], haystack: str) -> bool:
|
| 317 |
hay = (haystack or "").lower()
|
|
|
|
| 322 |
return all(n.strip().lower() in have_l for n in need if n and n.strip())
|
| 323 |
|
| 324 |
def is_eligible(row: pd.Series, shift: pd.Series) -> bool:
|
|
|
|
| 325 |
role_req = str(shift.get("RoleRequired","")).strip().lower()
|
| 326 |
specialty = str(shift.get("Specialty","")).replace("-", " ").strip().lower()
|
| 327 |
mandatory = [t.strip() for t in str(shift.get("MandatoryTraining","")).replace(",", "/").split("/") if t.strip()]
|
|
|
|
| 335 |
training_ok = (not mandatory) or _has_all_in_list(mandatory, row.get("Certifications", []))
|
| 336 |
ward_ok = (not ward) or _has_any([ward], row.get("organizationPath",""))
|
| 337 |
lang_ok = (not lang_req) or (lang_req in [l.lower() for l in (row.get("Languages") or [])])
|
|
|
|
| 338 |
return role_ok and spec_ok and training_ok and ward_ok and lang_ok
|
| 339 |
|
| 340 |
def gpt_decide(shift: pd.Series, eligible_df: pd.DataFrame) -> Dict[str, str]:
|
|
|
|
| 374 |
try:
|
| 375 |
resp = openai.ChatCompletion.create(
|
| 376 |
model="gpt-4",
|
| 377 |
+
messages=[
|
| 378 |
+
{"role":"system","content":"أنت مساعد ذكي لإدارة المناوبات الصحية"},
|
| 379 |
+
{"role":"user","content":prompt}],
|
| 380 |
temperature=0.4, max_tokens=200,
|
| 381 |
)
|
| 382 |
import ast
|
|
|
|
| 385 |
st.error(f"❌ GPT Error: {e}")
|
| 386 |
return {"action": "skip"}
|
| 387 |
|
| 388 |
+
# =============================================================================
|
| 389 |
+
# Timeline renderers
|
| 390 |
+
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
def render_runline_wide(events: List[Dict[str, Any]]) -> str:
|
| 392 |
+
"""Return HTML string for the wide Grid timeline (no backticks, safe for st.markdown)."""
|
| 393 |
+
def card(ev: Dict[str, Any]) -> str:
|
| 394 |
+
cls = ev.get("status", "done")
|
| 395 |
dur = ev.get("dur_ms")
|
| 396 |
+
dur_txt = f"{int(dur)} ms" if isinstance(dur, (int, float)) else "—"
|
| 397 |
+
title = str(ev.get("title", ""))
|
| 398 |
+
desc = str(ev.get("desc", ""))
|
| 399 |
+
return (
|
| 400 |
+
'<div class="run-card ' + cls + '">'
|
| 401 |
+
'<span class="dot"></span>'
|
| 402 |
+
'<div class="title">' + title + '</div>'
|
| 403 |
+
'<div class="meta">Duration: ' + dur_txt + '</div>'
|
| 404 |
+
'<div class="desc">' + desc + '</div>'
|
| 405 |
+
'</div>'
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
legend = (
|
| 409 |
+
'<div class="run-legend" dir="auto">'
|
| 410 |
+
'<span class="chip">● <b>Current</b></span>'
|
| 411 |
+
'<span class="chip">● <b>Done</b></span>'
|
| 412 |
+
'</div>'
|
| 413 |
+
)
|
| 414 |
+
grid = '<div class="runH" dir="auto">' + "".join(card(e) for e in events) + '</div>'
|
| 415 |
+
return legend + grid
|
| 416 |
|
| 417 |
# =============================================================================
|
| 418 |
+
# Agent main (unchanged logic)
|
| 419 |
# =============================================================================
|
| 420 |
def run_agent(employee_ids: Iterable[int], df_shifts: pd.DataFrame):
|
| 421 |
events: List[Dict[str, Any]] = []
|
|
|
|
| 438 |
|
| 439 |
# 3) AI decision
|
| 440 |
t_ai = time.perf_counter()
|
| 441 |
+
events.append({"title":"AI decision", "desc":"Select assign / notify / skip", "status":"done"})
|
| 442 |
decision = gpt_decide(shift, eligible)
|
| 443 |
ai_dur = (time.perf_counter() - t_ai) * 1000
|
| 444 |
events[-1]["dur_ms"] = max(int(ai_dur),1)
|
|
|
|
| 492 |
return events, shift_assignment_results, reasoning_rows
|
| 493 |
|
| 494 |
# =============================================================================
|
| 495 |
+
# UI
|
| 496 |
# =============================================================================
|
| 497 |
def main() -> None:
|
| 498 |
st.set_page_config(page_title="Health Matrix AI Command Center", layout="wide")
|
|
|
|
| 499 |
st.markdown(f"<style>{_EMBEDDED_CSS}</style>", unsafe_allow_html=True)
|
| 500 |
|
| 501 |
+
# ========== HERO ==========
|
| 502 |
st.markdown(
|
| 503 |
"""
|
| 504 |
<div class="hero" dir="auto">
|
| 505 |
<img src="https://www.healthmatrixcorp.com/web/image/website/1/logo/Health%20Matrix?unique=956ad7b" alt="Health Matrix" />
|
| 506 |
+
<h1>AI Command Center – Smart Staffing & Actions</h1>
|
| 507 |
+
<p>Desktop-first view. Uses full browser width to present the agent’s steps, decisions, and results clearly for operations teams.</p>
|
| 508 |
</div>
|
| 509 |
""", unsafe_allow_html=True
|
| 510 |
)
|
| 511 |
|
| 512 |
+
# ========== Business Case ==========
|
| 513 |
st.markdown("""
|
| 514 |
+
<div class="card" dir="auto" style="max-width:1400px;margin:0 auto 8px;">
|
| 515 |
+
<h4>Business Case — Open Shifts Auto-Fulfillment</h4>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
<ul>
|
| 517 |
+
<li>✅ Reduce time to fill critical shifts</li>
|
| 518 |
+
<li>📜 Enforce mandatory certifications & policies</li>
|
| 519 |
<li>📊 Transparent Agent Runline (steps + durations)</li>
|
| 520 |
</ul>
|
| 521 |
</div>
|
| 522 |
""", unsafe_allow_html=True)
|
| 523 |
|
| 524 |
+
# ========== Status ==========
|
| 525 |
status_placeholder = st.empty()
|
| 526 |
status_placeholder.markdown(
|
| 527 |
"""
|
|
|
|
| 534 |
""", unsafe_allow_html=True
|
| 535 |
)
|
| 536 |
|
| 537 |
+
# Demo input (unchanged behavior)
|
| 538 |
employee_ids_default = [850, 825]
|
| 539 |
shift_data = """ShiftID,Department,RoleRequired,Specialty,MandatoryTraining,ShiftType,ShiftDuration,WardDepartment,LanguageRequirement,ShiftTime
|
| 540 |
S101,ICU,Registered Nurse,ICU-trained,BLS/ALS certified,Night,12h,A&E,Arabic,2025-06-04 07:00"""
|
| 541 |
df_shifts_default = pd.read_csv(StringIO(shift_data))
|
| 542 |
|
| 543 |
+
# CTA centered
|
| 544 |
+
_, cta, _ = st.columns([1,2,1])
|
| 545 |
+
with cta:
|
| 546 |
+
run_clicked = st.button("▶️ Start AI Agent", key="start_agent", help="Let the AI handle the work")
|
| 547 |
+
|
| 548 |
+
if run_clicked:
|
| 549 |
+
# Processing state
|
| 550 |
+
status_placeholder.markdown(
|
| 551 |
+
"""
|
| 552 |
<div class="status-wrap" role="status" aria-live="polite">
|
| 553 |
<div class="status-bar">
|
| 554 |
<span class="pulse-dot"></span>
|
|
|
|
| 556 |
</div>
|
| 557 |
</div>
|
| 558 |
""", unsafe_allow_html=True
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
# Lightweight skeletons (visual only)
|
| 562 |
+
skel_top = st.empty()
|
| 563 |
+
skel_mid = st.empty()
|
| 564 |
+
skel_top.markdown('<div class="skel h120"></div>', unsafe_allow_html=True)
|
| 565 |
+
skel_mid.markdown('<div class="skel h80" style="margin-top:8px"></div>', unsafe_allow_html=True)
|
| 566 |
+
|
| 567 |
+
# Run agent (logic unchanged)
|
| 568 |
+
try:
|
| 569 |
+
events, shift_assignment_results, reasoning_rows = run_agent(employee_ids_default, df_shifts_default)
|
| 570 |
+
except Exception as e:
|
| 571 |
+
skel_top.empty(); skel_mid.empty()
|
| 572 |
+
st.error(f"Unexpected error: {e}")
|
| 573 |
+
return
|
| 574 |
+
|
| 575 |
+
# Clear skeletons
|
| 576 |
+
skel_top.empty(); skel_mid.empty()
|
| 577 |
+
|
| 578 |
+
# Completed status
|
| 579 |
+
status_placeholder.markdown(
|
| 580 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
<div class="status-wrap" role="status" aria-live="polite">
|
| 582 |
<div class="status-bar status-done">
|
| 583 |
<span class="pulse-dot"></span>
|
|
|
|
| 585 |
</div>
|
| 586 |
</div>
|
| 587 |
""", unsafe_allow_html=True
|
| 588 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
|
| 590 |
+
# Agent Overview
|
| 591 |
+
total_steps = len(events)
|
| 592 |
+
total_ms = sum(int(e.get("dur_ms", 0) or 0) for e in events)
|
| 593 |
+
assigned = sum(1 for e in events if e["title"].startswith("Assigned"))
|
| 594 |
+
notified = sum(1 for e in events if e["title"].startswith("Notify"))
|
| 595 |
+
skipped = sum(1 for e in events if e["title"].startswith("Skipped"))
|
| 596 |
+
st.markdown('<div class="overview">', unsafe_allow_html=True)
|
| 597 |
+
st.markdown(
|
| 598 |
+
f'<span class="chip">🧠 <b>Agent Steps:</b> {total_steps}</span>'
|
| 599 |
+
f'<span class="chip">⏱️ <b>Total Duration:</b> {total_ms} ms</span>'
|
| 600 |
+
f'<span class="chip">✅ <b>Assigned:</b> {assigned}</span>'
|
| 601 |
+
f'<span class="chip">📬 <b>Notify:</b> {notified}</span>'
|
| 602 |
+
f'<span class="chip">⚠️ <b>Skipped:</b> {skipped}</span>',
|
| 603 |
+
unsafe_allow_html=True
|
| 604 |
+
)
|
| 605 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 606 |
+
|
| 607 |
+
# --------- WIDE TIMELINE (Grid) -----------
|
| 608 |
+
st.markdown("### 🕒 Agent Runline")
|
| 609 |
+
# Important: use st.markdown with unsafe_allow_html=True (NOT st.write), to avoid escaped tags
|
| 610 |
+
st.markdown(render_runline_wide(events), unsafe_allow_html=True)
|
| 611 |
+
|
| 612 |
+
# KPIs
|
| 613 |
+
st.markdown('<div class="stat-row">', unsafe_allow_html=True)
|
| 614 |
+
st.markdown(
|
| 615 |
+
f'<div class="stat-card"><div class="stat-icon">✅</div><div class="stat-label">Assigned</div><div class="stat-value">{assigned}</div></div>'
|
| 616 |
+
f'<div class="stat-card"><div class="stat-icon">📬</div><div class="stat-label">Notify</div><div class="stat-value">{notified}</div></div>'
|
| 617 |
+
f'<div class="stat-card"><div class="stat-icon">⚠️</div><div class="stat-label">Skipped</div><div class="stat-value">{skipped}</div></div>',
|
| 618 |
+
unsafe_allow_html=True
|
| 619 |
+
)
|
| 620 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 621 |
|
| 622 |
+
# Results (tables use full container width; horizontal scroll wrapper present on narrow)
|
| 623 |
+
st.markdown("### 📊 Results")
|
| 624 |
+
|
| 625 |
+
with st.expander("Shift Fulfillment Summary", expanded=True):
|
| 626 |
+
if shift_assignment_results:
|
| 627 |
+
st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
|
| 628 |
+
st.dataframe(pd.DataFrame(shift_assignment_results, columns=["Employee", "ShiftID", "Status"]),
|
| 629 |
+
use_container_width=True)
|
| 630 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 631 |
+
else:
|
| 632 |
+
st.info("No assignments to display.")
|
| 633 |
+
|
| 634 |
+
with st.expander("Reasoning Behind Selections", expanded=False):
|
| 635 |
+
if reasoning_rows:
|
| 636 |
+
st.markdown('<div class="table-wrap">', unsafe_allow_html=True)
|
| 637 |
+
st.dataframe(pd.DataFrame(reasoning_rows), use_container_width=True)
|
| 638 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 639 |
+
else:
|
| 640 |
+
st.info("No reasoning data available.")
|
| 641 |
+
|
| 642 |
+
else:
|
| 643 |
+
# Idle info
|
| 644 |
+
st.markdown(
|
| 645 |
+
"""
|
| 646 |
+
<div class="card" dir="auto" style="max-width:1200px;margin:8px auto 0;">
|
| 647 |
<h4>How it works</h4>
|
| 648 |
<ol style="margin:6px 0 0 18px;color:#334155">
|
| 649 |
<li>Fetch employees & certifications from UKG demo API</li>
|
|
|
|
| 653 |
</ol>
|
| 654 |
</div>
|
| 655 |
""", unsafe_allow_html=True
|
| 656 |
+
)
|
| 657 |
|
| 658 |
+
# Footer
|
| 659 |
st.markdown(
|
| 660 |
"""
|
| 661 |
<hr/>
|
| 662 |
+
<div style="text-align:center;color:#64748B;font-size:13px">
|
| 663 |
© 2025 Health Matrix Corp – Empowering Digital Health Transformation · <a href="mailto:info@healthmatrixcorp.com">info@healthmatrixcorp.com</a>
|
| 664 |
</div>
|
| 665 |
""", unsafe_allow_html=True
|