Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +214 -72
src/streamlit_app.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
"""
|
| 2 |
-
Health Matrix – AI Command Center (Desktop Wide +
|
| 3 |
-
- Sticky Left
|
| 4 |
-
-
|
| 5 |
-
-
|
| 6 |
-
-
|
|
|
|
| 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 (
|
| 21 |
try:
|
| 22 |
import openai # type: ignore
|
| 23 |
HAS_OPENAI = True
|
|
@@ -27,7 +28,7 @@ except Exception:
|
|
| 27 |
|
| 28 |
|
| 29 |
# =============================================================================
|
| 30 |
-
# Embedded CSS
|
| 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 |
-
.
|
| 108 |
-
.
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
-
.
|
| 113 |
-
.
|
| 114 |
-
.
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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 }
|
| 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
|
| 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
|
| 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 +
|
| 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(
|
| 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 |
-
|
| 622 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 637 |
-
st.info("No reasoning data available.")
|
| 638 |
else:
|
| 639 |
-
st.info("Click
|
| 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(
|