Updated the strings
Browse files- src/streamlit_app.py +23 -185
src/streamlit_app.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
"""
|
| 4 |
-
Enterprise Roster Generator
|
| 5 |
All constraints adapt dynamically based on team size to ensure feasibility.
|
| 6 |
"""
|
| 7 |
|
|
@@ -324,9 +324,9 @@ def get_weekly_requirements(n_staff: int) -> tuple:
|
|
| 324 |
# cumulative_shifts: dict[str, int],
|
| 325 |
# all_staff_global: list[str],
|
| 326 |
# ) -> tuple[dict, dict]:
|
| 327 |
-
N_AVAIL = len(available_staff)
|
| 328 |
-
if N_AVAIL < 5:
|
| 329 |
-
|
| 330 |
|
| 331 |
# Get coverage rules
|
| 332 |
wd_day, wd_night, we_day, we_night, reduced_days = get_weekly_requirements(N_AVAIL)
|
|
@@ -445,168 +445,6 @@ def get_weekly_requirements(n_staff: int) -> tuple:
|
|
| 445 |
return schedule_week, weekly_counts
|
| 446 |
|
| 447 |
|
| 448 |
-
# def solve_week(
|
| 449 |
-
# week_idx: int,
|
| 450 |
-
# start_date: datetime.date,
|
| 451 |
-
# available_staff: list[str],
|
| 452 |
-
# cumulative_shifts: dict[str, int],
|
| 453 |
-
# all_staff_global: list[str],
|
| 454 |
-
# ) -> tuple[dict, dict]:
|
| 455 |
-
# """Solve one week with constraints that adapt to team size."""
|
| 456 |
-
# N_AVAIL = len(available_staff)
|
| 457 |
-
# if N_AVAIL < 5:
|
| 458 |
-
# raise ValueError("Minimum 5 staff per week required.")
|
| 459 |
-
|
| 460 |
-
# # Determine constraint strictness based on staff count
|
| 461 |
-
# IS_LARGE_TEAM = N_AVAIL >= 8 # 8-9 staff get strictest constraints
|
| 462 |
-
# IS_MEDIUM_TEAM = N_AVAIL == 7 # 7 staff get moderate constraints
|
| 463 |
-
|
| 464 |
-
# # Get coverage rules
|
| 465 |
-
# wd_day, wd_night, we_day, we_night, reduced_days = get_weekly_requirements(N_AVAIL)
|
| 466 |
-
|
| 467 |
-
# full_names = available_staff + [f"Vacant_{i}" for i in range(9 - N_AVAIL)]
|
| 468 |
-
# SHIFT = {"day": 0, "night": 1}
|
| 469 |
-
# DAYS = 7
|
| 470 |
-
# WEEKDAY_REL = {0, 1, 2, 3, 4}
|
| 471 |
-
|
| 472 |
-
# model = cp_model.CpModel()
|
| 473 |
-
# x = {}
|
| 474 |
-
# for p, d, s in itertools.product(range(9), range(DAYS), range(2)):
|
| 475 |
-
# x[p, d, s] = model.NewBoolVar(f"x_{p}_{d}_{s}")
|
| 476 |
-
|
| 477 |
-
# # === Dynamic Coverage ===
|
| 478 |
-
# for d in range(DAYS):
|
| 479 |
-
# if d in WEEKDAY_REL:
|
| 480 |
-
# day_req = wd_day
|
| 481 |
-
# if d in reduced_days:
|
| 482 |
-
# day_req = 2
|
| 483 |
-
# night_req = wd_night
|
| 484 |
-
# else:
|
| 485 |
-
# day_req = we_day
|
| 486 |
-
# night_req = we_night
|
| 487 |
-
# model.Add(sum(x[p, d, SHIFT["day"]] for p in range(9)) == day_req)
|
| 488 |
-
# model.Add(sum(x[p, d, SHIFT["night"]] for p in range(9)) == night_req)
|
| 489 |
-
|
| 490 |
-
# # === No same-day double shift (always enforced) ===
|
| 491 |
-
# for p, d in itertools.product(range(9), range(DAYS)):
|
| 492 |
-
# model.Add(x[p, d, SHIFT["day"]] + x[p, d, SHIFT["night"]] <= 1)
|
| 493 |
-
|
| 494 |
-
# # === No consecutive days (adaptive) ===
|
| 495 |
-
# for p in range(9):
|
| 496 |
-
# for d in range(DAYS - 1):
|
| 497 |
-
# if IS_LARGE_TEAM:
|
| 498 |
-
# # Full restriction for large teams
|
| 499 |
-
# model.Add(
|
| 500 |
-
# x[p, d, SHIFT["day"]]
|
| 501 |
-
# + x[p, d, SHIFT["night"]]
|
| 502 |
-
# + x[p, d + 1, SHIFT["day"]]
|
| 503 |
-
# + x[p, d + 1, SHIFT["night"]]
|
| 504 |
-
# <= 1
|
| 505 |
-
# )
|
| 506 |
-
# else:
|
| 507 |
-
# # Only prevent consecutive NIGHT shifts for smaller teams
|
| 508 |
-
# model.Add(x[p, d, SHIFT["night"]] + x[p, d + 1, SHIFT["night"]] <= 1)
|
| 509 |
-
|
| 510 |
-
# # === Weekend cap (always enforced) ===
|
| 511 |
-
# for p in range(9):
|
| 512 |
-
# model.Add(sum(x[p, d, s] for d in (5, 6) for s in range(2)) <= 1)
|
| 513 |
-
|
| 514 |
-
# # === 48h rest after night shift (adaptive) ===
|
| 515 |
-
# for p in range(9):
|
| 516 |
-
# for d in range(DAYS):
|
| 517 |
-
# night_d = x[p, d, SHIFT["night"]]
|
| 518 |
-
# # Always enforce 24h rest (no shift next day after night)
|
| 519 |
-
# if d + 1 < DAYS:
|
| 520 |
-
# any_d1 = x[p, d + 1, SHIFT["day"]] + x[p, d + 1, SHIFT["night"]]
|
| 521 |
-
# model.Add(any_d1 <= 1 - night_d)
|
| 522 |
-
|
| 523 |
-
# # Only enforce second day off for large teams
|
| 524 |
-
# if IS_LARGE_TEAM and d + 2 < DAYS:
|
| 525 |
-
# any_d2 = x[p, d + 2, SHIFT["day"]] + x[p, d + 2, SHIFT["night"]]
|
| 526 |
-
# model.Add(any_d2 <= 1 - night_d)
|
| 527 |
-
|
| 528 |
-
# # === Vacants forced to 0 ===
|
| 529 |
-
# for p in range(N_AVAIL, 9):
|
| 530 |
-
# for d, s in itertools.product(range(DAYS), range(2)):
|
| 531 |
-
# model.Add(x[p, d, s] == 0)
|
| 532 |
-
|
| 533 |
-
# # === Weekly bounds (adaptive) ===
|
| 534 |
-
# if N_AVAIL <= 6:
|
| 535 |
-
# MIN_WEEKLY, MAX_WEEKLY = 3, 4 # Small teams work more shifts
|
| 536 |
-
# elif N_AVAIL == 7:
|
| 537 |
-
# MIN_WEEKLY, MAX_WEEKLY = 2, 4 # Medium teams need flexibility
|
| 538 |
-
# else: # 8-9 staff
|
| 539 |
-
# MIN_WEEKLY, MAX_WEEKLY = 2, 3
|
| 540 |
-
|
| 541 |
-
# week_shifts = {}
|
| 542 |
-
# for i, name in enumerate(available_staff):
|
| 543 |
-
# var = model.NewIntVar(MIN_WEEKLY, MAX_WEEKLY, f"wshift_{i}")
|
| 544 |
-
# model.Add(var == sum(x[i, d, s] for d in range(DAYS) for s in range(2)))
|
| 545 |
-
# week_shifts[name] = var
|
| 546 |
-
|
| 547 |
-
# # === Soft fairness objective ===
|
| 548 |
-
# objective_terms = []
|
| 549 |
-
# for i, name in enumerate(available_staff):
|
| 550 |
-
# cum = cumulative_shifts.get(name, 0)
|
| 551 |
-
# objective_terms.append(cum * week_shifts[name])
|
| 552 |
-
# model.Minimize(sum(objective_terms))
|
| 553 |
-
|
| 554 |
-
# solver = cp_model.CpSolver()
|
| 555 |
-
# # Longer timeout for smaller teams with tighter constraints
|
| 556 |
-
# solver.parameters.max_time_in_seconds = 45.0 if N_AVAIL <= 7 else 30.0
|
| 557 |
-
# solver.parameters.num_search_workers = 6
|
| 558 |
-
|
| 559 |
-
# status = solver.Solve(model)
|
| 560 |
-
# if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
| 561 |
-
# status_map = {
|
| 562 |
-
# cp_model.UNKNOWN: "UNKNOWN",
|
| 563 |
-
# cp_model.MODEL_INVALID: "MODEL_INVALID",
|
| 564 |
-
# cp_model.FEASIBLE: "FEASIBLE",
|
| 565 |
-
# cp_model.INFEASIBLE: "INFEASIBLE",
|
| 566 |
-
# cp_model.OPTIMAL: "OPTIMAL",
|
| 567 |
-
# }
|
| 568 |
-
# status_name = status_map.get(status, f"Status {status}")
|
| 569 |
-
|
| 570 |
-
# error_msg = (
|
| 571 |
-
# f"Week {week_idx + 1} solver returned: {status_name} with {N_AVAIL} staff\n"
|
| 572 |
-
# )
|
| 573 |
-
# error_msg += "Active constraints:\n"
|
| 574 |
-
# error_msg += (
|
| 575 |
-
# f"- No consecutive days: {'FULL' if IS_LARGE_TEAM else 'NIGHT ONLY'}\n"
|
| 576 |
-
# )
|
| 577 |
-
# error_msg += f"- Night rest: {'48h' if IS_LARGE_TEAM else '24h'}\n"
|
| 578 |
-
# error_msg += f"- Weekly bounds: {MIN_WEEKLY}-{MAX_WEEKLY} shifts\n"
|
| 579 |
-
# error_msg += (
|
| 580 |
-
# f"- Coverage: {wd_day}/{wd_night} weekdays, {we_day}/{we_night} weekends"
|
| 581 |
-
# )
|
| 582 |
-
# if reduced_days:
|
| 583 |
-
# error_msg += (
|
| 584 |
-
# f"\n- Reduced day(s): {', '.join(str(d) for d in reduced_days)}"
|
| 585 |
-
# )
|
| 586 |
-
# raise RuntimeError(error_msg)
|
| 587 |
-
|
| 588 |
-
# # === Extract solution ===
|
| 589 |
-
# schedule_week = {}
|
| 590 |
-
# weekly_counts = {name: 0 for name in available_staff}
|
| 591 |
-
# for d in range(7):
|
| 592 |
-
# day_staff = [
|
| 593 |
-
# full_names[p]
|
| 594 |
-
# for p in range(9)
|
| 595 |
-
# if solver.Value(x[p, d, SHIFT["day"]])
|
| 596 |
-
# and not full_names[p].startswith("Vacant_")
|
| 597 |
-
# ]
|
| 598 |
-
# night_staff = [
|
| 599 |
-
# full_names[p]
|
| 600 |
-
# for p in range(9)
|
| 601 |
-
# if solver.Value(x[p, d, SHIFT["night"]])
|
| 602 |
-
# and not full_names[p].startswith("Vacant_")
|
| 603 |
-
# ]
|
| 604 |
-
# schedule_week[d] = {"day": day_staff, "night": night_staff}
|
| 605 |
-
# for name in day_staff + night_staff:
|
| 606 |
-
# weekly_counts[name] += 1
|
| 607 |
-
|
| 608 |
-
# return schedule_week, weekly_counts
|
| 609 |
-
|
| 610 |
|
| 611 |
def solve_week(
|
| 612 |
week_idx: int,
|
|
@@ -1037,10 +875,10 @@ def export_ics(schedule, start_date, output_path):
|
|
| 1037 |
|
| 1038 |
|
| 1039 |
# ------------------------------
|
| 1040 |
-
# Streamlit App
|
| 1041 |
# ------------------------------
|
| 1042 |
st.set_page_config(page_title="Enterprise Roster (Final)", layout="wide")
|
| 1043 |
-
st.title("
|
| 1044 |
|
| 1045 |
# === Initialize session state (FIRST RUN ONLY) ===
|
| 1046 |
if "initialized" not in st.session_state:
|
|
@@ -1058,7 +896,7 @@ if "initialized" not in st.session_state:
|
|
| 1058 |
st.session_state.roster_ready = False
|
| 1059 |
|
| 1060 |
# === Auth UI ===
|
| 1061 |
-
st.sidebar.header("
|
| 1062 |
role = st.sidebar.radio(
|
| 1063 |
"Role",
|
| 1064 |
["Manager", "Staff"],
|
|
@@ -1105,7 +943,7 @@ if st.session_state.user_role == "manager":
|
|
| 1105 |
|
| 1106 |
st.header("3. Weekly Availability (Holiday/Mission)")
|
| 1107 |
st.markdown(
|
| 1108 |
-
"Uncheck staff who are unavailable (max 4 absent/week
|
| 1109 |
)
|
| 1110 |
avail_matrix = {}
|
| 1111 |
cols_w = st.columns(6)
|
|
@@ -1122,7 +960,7 @@ if st.session_state.user_role == "manager":
|
|
| 1122 |
if len(available) < 5:
|
| 1123 |
st.error("⚠️ ≥5 must be available")
|
| 1124 |
|
| 1125 |
-
if st.button("
|
| 1126 |
try:
|
| 1127 |
names_all = [n for n in st.session_state.names if n]
|
| 1128 |
if not names_all:
|
|
@@ -1155,7 +993,7 @@ if st.session_state.user_role == "manager":
|
|
| 1155 |
# Check if we need reduced coverage
|
| 1156 |
if len(avail) == 5:
|
| 1157 |
st.warning(
|
| 1158 |
-
f"
|
| 1159 |
)
|
| 1160 |
|
| 1161 |
for w in range(6):
|
|
@@ -1195,7 +1033,7 @@ if st.session_state.user_role == "manager":
|
|
| 1195 |
f"roster_{st.session_state.start_date:%Y%m%d}.pkl", data
|
| 1196 |
)
|
| 1197 |
if fid:
|
| 1198 |
-
st.info(f"
|
| 1199 |
except Exception as e:
|
| 1200 |
st.warning(f"Drive save failed: {e}")
|
| 1201 |
|
|
@@ -1207,11 +1045,11 @@ if st.session_state.user_role == "manager":
|
|
| 1207 |
schedule_weekly_emails(
|
| 1208 |
full_sched, emails_all, st.session_state.start_date
|
| 1209 |
)
|
| 1210 |
-
st.info("
|
| 1211 |
except Exception as e:
|
| 1212 |
st.warning(f"Email setup failed: {e}")
|
| 1213 |
|
| 1214 |
-
st.success("
|
| 1215 |
|
| 1216 |
except Exception as e:
|
| 1217 |
st.error(f"Generation failed: {e}")
|
|
@@ -1231,11 +1069,11 @@ if st.session_state.roster_ready:
|
|
| 1231 |
|
| 1232 |
if reduced_weeks:
|
| 1233 |
st.warning(
|
| 1234 |
-
f"
|
| 1235 |
"(one weekday day shift = 2 staff instead of 3)."
|
| 1236 |
)
|
| 1237 |
|
| 1238 |
-
st.header("
|
| 1239 |
rows = []
|
| 1240 |
for d in range(42):
|
| 1241 |
dt = st.session_state.start_date + timedelta(days=d)
|
|
@@ -1255,7 +1093,7 @@ if st.session_state.roster_ready:
|
|
| 1255 |
df = pd.DataFrame(rows)
|
| 1256 |
st.dataframe(df, use_container_width=True, hide_index=True)
|
| 1257 |
|
| 1258 |
-
st.subheader("
|
| 1259 |
summ = []
|
| 1260 |
for name in [n for n in st.session_state.names if n]:
|
| 1261 |
summ.append(
|
|
@@ -1270,7 +1108,7 @@ if st.session_state.roster_ready:
|
|
| 1270 |
c1, c2, c3, c4 = st.columns(4)
|
| 1271 |
with c1:
|
| 1272 |
st.download_button(
|
| 1273 |
-
"
|
| 1274 |
df.to_csv(index=False).encode(),
|
| 1275 |
"roster.csv",
|
| 1276 |
"text/csv",
|
|
@@ -1283,7 +1121,7 @@ if st.session_state.roster_ready:
|
|
| 1283 |
):
|
| 1284 |
with open(f.name, "rb") as pf:
|
| 1285 |
st.download_button(
|
| 1286 |
-
"
|
| 1287 |
pf.read(),
|
| 1288 |
"roster.pdf",
|
| 1289 |
"application/pdf",
|
|
@@ -1295,7 +1133,7 @@ if st.session_state.roster_ready:
|
|
| 1295 |
if export_ics(full_sched, st.session_state.start_date, Path(f.name)):
|
| 1296 |
with open(f.name, "rb") as pf:
|
| 1297 |
st.download_button(
|
| 1298 |
-
"
|
| 1299 |
pf.read(),
|
| 1300 |
"roster.ics",
|
| 1301 |
"text/calendar",
|
|
@@ -1303,19 +1141,19 @@ if st.session_state.roster_ready:
|
|
| 1303 |
)
|
| 1304 |
os.unlink(f.name)
|
| 1305 |
with c4:
|
| 1306 |
-
if st.button("
|
| 1307 |
st.session_state.roster_ready = False
|
| 1308 |
st.session_state.roster_weekly = {}
|
| 1309 |
st.session_state.cumulative_shifts = {}
|
| 1310 |
st.rerun()
|
| 1311 |
|
| 1312 |
# Drive Load
|
| 1313 |
-
st.subheader("
|
| 1314 |
files = list_drive_files()
|
| 1315 |
if files:
|
| 1316 |
opts = {f["title"]: f["id"] for f in files}
|
| 1317 |
sel = st.selectbox("Select roster", list(opts.keys()), key="drive_select")
|
| 1318 |
-
if st.button("
|
| 1319 |
try:
|
| 1320 |
data = pickle.loads(load_from_drive(opts[sel]))
|
| 1321 |
st.session_state.roster_weekly = data["weekly"]
|
|
@@ -1343,7 +1181,7 @@ if st.session_state.roster_ready:
|
|
| 1343 |
if not staff_name:
|
| 1344 |
st.warning("Enter an email matching a staff member.")
|
| 1345 |
else:
|
| 1346 |
-
st.header(f"
|
| 1347 |
my_shifts = []
|
| 1348 |
for d in range(42):
|
| 1349 |
dt = st.session_state.start_date + timedelta(days=d)
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
"""
|
| 4 |
+
Enterprise Roster Generator ‚Final Adaptive Version
|
| 5 |
All constraints adapt dynamically based on team size to ensure feasibility.
|
| 6 |
"""
|
| 7 |
|
|
|
|
| 324 |
# cumulative_shifts: dict[str, int],
|
| 325 |
# all_staff_global: list[str],
|
| 326 |
# ) -> tuple[dict, dict]:
|
| 327 |
+
# N_AVAIL = len(available_staff)
|
| 328 |
+
# if N_AVAIL < 5:
|
| 329 |
+
# raise ValueError("Minimum 5 staff per week.")
|
| 330 |
|
| 331 |
# Get coverage rules
|
| 332 |
wd_day, wd_night, we_day, we_night, reduced_days = get_weekly_requirements(N_AVAIL)
|
|
|
|
| 445 |
return schedule_week, weekly_counts
|
| 446 |
|
| 447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
|
| 449 |
def solve_week(
|
| 450 |
week_idx: int,
|
|
|
|
| 875 |
|
| 876 |
|
| 877 |
# ------------------------------
|
| 878 |
+
# Streamlit App ‚CORRECTED SESSION STATE HANDLING
|
| 879 |
# ------------------------------
|
| 880 |
st.set_page_config(page_title="Enterprise Roster (Final)", layout="wide")
|
| 881 |
+
st.title("Enterprise Roster Generator ‚Final")
|
| 882 |
|
| 883 |
# === Initialize session state (FIRST RUN ONLY) ===
|
| 884 |
if "initialized" not in st.session_state:
|
|
|
|
| 896 |
st.session_state.roster_ready = False
|
| 897 |
|
| 898 |
# === Auth UI ===
|
| 899 |
+
st.sidebar.header("Access")
|
| 900 |
role = st.sidebar.radio(
|
| 901 |
"Role",
|
| 902 |
["Manager", "Staff"],
|
|
|
|
| 943 |
|
| 944 |
st.header("3. Weekly Availability (Holiday/Mission)")
|
| 945 |
st.markdown(
|
| 946 |
+
"Uncheck staff who are unavailable (max 4 absent/week ‚min 5 available)."
|
| 947 |
)
|
| 948 |
avail_matrix = {}
|
| 949 |
cols_w = st.columns(6)
|
|
|
|
| 960 |
if len(available) < 5:
|
| 961 |
st.error("⚠️ ≥5 must be available")
|
| 962 |
|
| 963 |
+
if st.button("Generate Rolling Roster", type="primary", key="generate_btn"):
|
| 964 |
try:
|
| 965 |
names_all = [n for n in st.session_state.names if n]
|
| 966 |
if not names_all:
|
|
|
|
| 993 |
# Check if we need reduced coverage
|
| 994 |
if len(avail) == 5:
|
| 995 |
st.warning(
|
| 996 |
+
f"Week {w + 1}: Reduced coverage mode (one weekday day shift = 2 staff)"
|
| 997 |
)
|
| 998 |
|
| 999 |
for w in range(6):
|
|
|
|
| 1033 |
f"roster_{st.session_state.start_date:%Y%m%d}.pkl", data
|
| 1034 |
)
|
| 1035 |
if fid:
|
| 1036 |
+
st.info(f"Saved to Drive (ID: {fid[:8]}…)")
|
| 1037 |
except Exception as e:
|
| 1038 |
st.warning(f"Drive save failed: {e}")
|
| 1039 |
|
|
|
|
| 1045 |
schedule_weekly_emails(
|
| 1046 |
full_sched, emails_all, st.session_state.start_date
|
| 1047 |
)
|
| 1048 |
+
st.info("Weekly email reminders scheduled.")
|
| 1049 |
except Exception as e:
|
| 1050 |
st.warning(f"Email setup failed: {e}")
|
| 1051 |
|
| 1052 |
+
st.success("Rolling roster generated!")
|
| 1053 |
|
| 1054 |
except Exception as e:
|
| 1055 |
st.error(f"Generation failed: {e}")
|
|
|
|
| 1069 |
|
| 1070 |
if reduced_weeks:
|
| 1071 |
st.warning(
|
| 1072 |
+
f"Reduced coverage in Week(s): {', '.join(map(str, reduced_weeks))} "
|
| 1073 |
"(one weekday day shift = 2 staff instead of 3)."
|
| 1074 |
)
|
| 1075 |
|
| 1076 |
+
st.header("Full 6 Week Roster")
|
| 1077 |
rows = []
|
| 1078 |
for d in range(42):
|
| 1079 |
dt = st.session_state.start_date + timedelta(days=d)
|
|
|
|
| 1093 |
df = pd.DataFrame(rows)
|
| 1094 |
st.dataframe(df, use_container_width=True, hide_index=True)
|
| 1095 |
|
| 1096 |
+
st.subheader("Cumulative Shifts")
|
| 1097 |
summ = []
|
| 1098 |
for name in [n for n in st.session_state.names if n]:
|
| 1099 |
summ.append(
|
|
|
|
| 1108 |
c1, c2, c3, c4 = st.columns(4)
|
| 1109 |
with c1:
|
| 1110 |
st.download_button(
|
| 1111 |
+
"CSV",
|
| 1112 |
df.to_csv(index=False).encode(),
|
| 1113 |
"roster.csv",
|
| 1114 |
"text/csv",
|
|
|
|
| 1121 |
):
|
| 1122 |
with open(f.name, "rb") as pf:
|
| 1123 |
st.download_button(
|
| 1124 |
+
"PDF",
|
| 1125 |
pf.read(),
|
| 1126 |
"roster.pdf",
|
| 1127 |
"application/pdf",
|
|
|
|
| 1133 |
if export_ics(full_sched, st.session_state.start_date, Path(f.name)):
|
| 1134 |
with open(f.name, "rb") as pf:
|
| 1135 |
st.download_button(
|
| 1136 |
+
"ICS",
|
| 1137 |
pf.read(),
|
| 1138 |
"roster.ics",
|
| 1139 |
"text/calendar",
|
|
|
|
| 1141 |
)
|
| 1142 |
os.unlink(f.name)
|
| 1143 |
with c4:
|
| 1144 |
+
if st.button("Clear", key="clear_btn"):
|
| 1145 |
st.session_state.roster_ready = False
|
| 1146 |
st.session_state.roster_weekly = {}
|
| 1147 |
st.session_state.cumulative_shifts = {}
|
| 1148 |
st.rerun()
|
| 1149 |
|
| 1150 |
# Drive Load
|
| 1151 |
+
st.subheader("Load from Drive")
|
| 1152 |
files = list_drive_files()
|
| 1153 |
if files:
|
| 1154 |
opts = {f["title"]: f["id"] for f in files}
|
| 1155 |
sel = st.selectbox("Select roster", list(opts.keys()), key="drive_select")
|
| 1156 |
+
if st.button("Load Selected", key="load_btn"):
|
| 1157 |
try:
|
| 1158 |
data = pickle.loads(load_from_drive(opts[sel]))
|
| 1159 |
st.session_state.roster_weekly = data["weekly"]
|
|
|
|
| 1181 |
if not staff_name:
|
| 1182 |
st.warning("Enter an email matching a staff member.")
|
| 1183 |
else:
|
| 1184 |
+
st.header(f"Your Shifts, {staff_name}")
|
| 1185 |
my_shifts = []
|
| 1186 |
for d in range(42):
|
| 1187 |
dt = st.session_state.start_date + timedelta(days=d)
|