feat(cli): improve member design and usability

This commit is contained in:
2025-08-28 16:21:39 -04:00
parent 954abb704e
commit 94900b19f7
7 changed files with 453 additions and 106 deletions

View File

@@ -5,7 +5,7 @@ CLI command modules.
from .members import cmd_members_list, cmd_members_show, setup_members_parser
from .schedules import (
cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept,
cmd_schedules_decline, cmd_schedules_schedule, setup_schedules_parser
cmd_schedules_decline, cmd_schedules_remove, cmd_schedules_schedule, setup_schedules_parser
)
from .services import cmd_services_list, setup_services_parser
@@ -14,7 +14,7 @@ __all__ = [
"cmd_members_list", "cmd_members_show", "setup_members_parser",
# Schedule commands
"cmd_schedules_list", "cmd_schedules_show", "cmd_schedules_accept",
"cmd_schedules_decline", "cmd_schedules_schedule", "setup_schedules_parser",
"cmd_schedules_decline", "cmd_schedules_remove", "cmd_schedules_schedule", "setup_schedules_parser",
# Service commands
"cmd_services_list", "setup_services_parser",
]

View File

@@ -21,6 +21,17 @@ def cmd_schedules_list(cli: "NimbusFlowCLI", args) -> None:
if args.service_id:
schedules = [s for s in schedules if s.ServiceId == args.service_id]
if hasattr(args, 'date') and args.date:
# Filter schedules by date - find services for that date first
try:
from datetime import date as date_type
target_date = date_type.fromisoformat(args.date)
services_on_date = [s.ServiceId for s in cli.service_repo.list_all() if s.ServiceDate == target_date]
schedules = [s for s in schedules if s.ServiceId in services_on_date]
except ValueError:
print(f"{TableColors.ERROR}Invalid date format '{args.date}'. Use YYYY-MM-DD{TableColors.RESET}")
return
if args.status:
try:
status_enum = ScheduleStatus.from_raw(args.status.lower())
@@ -128,15 +139,18 @@ def cmd_schedules_accept(cli: "NimbusFlowCLI", args) -> None:
service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()}
# Show available services for the date
print(f"\n📅 Services available on {args.date}:")
print("-" * 40)
print(f"\n{TableColors.HEADER}Services available on {args.date}{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 50 + f"{TableColors.RESET}")
print()
for i, service in enumerate(services_on_date, 1):
type_name = service_type_map.get(service.ServiceTypeId, "Unknown")
print(f"{i}. {type_name} (Service ID: {service.ServiceId})")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {type_name}")
print()
# Let user select service
try:
choice = input(f"\nSelect service (1-{len(services_on_date)}): ").strip()
print(f"\n{TableColors.INPUT_BOX}┌─ Select service (1-{len(services_on_date)}) ─┐{TableColors.RESET}")
choice = input(f"{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
return
@@ -167,72 +181,321 @@ def cmd_schedules_accept(cli: "NimbusFlowCLI", args) -> None:
member_map = {m.MemberId: m for m in cli.member_repo.list_all()}
# Show available members to accept
print(f"\n👥 Members scheduled for {service_type_map.get(selected_service.ServiceTypeId, 'Unknown')} on {args.date}:")
print("-" * 60)
print(f"\n{TableColors.HEADER}Members scheduled for {service_type_map.get(selected_service.ServiceTypeId, 'Unknown')} on {args.date}:{TableColors.RESET}")
print(f"{TableColors.BORDER}{'' * 70}{TableColors.RESET}")
for i, schedule in enumerate(pending_schedules, 1):
member = member_map.get(schedule.MemberId)
if member:
print(f"{i}. {member.FirstName} {member.LastName} (Schedule ID: {schedule.ScheduleId})")
print(f"{TableColors.CYAN}{i:2d}.{TableColors.RESET} {TableColors.BOLD}{member.FirstName} {member.LastName}{TableColors.RESET} {TableColors.DIM}(Schedule ID: {schedule.ScheduleId}){TableColors.RESET}")
else:
print(f"{i}. Unknown Member (ID: {schedule.MemberId}) (Schedule ID: {schedule.ScheduleId})")
print(f"{TableColors.CYAN}{i:2d}.{TableColors.RESET} {TableColors.DIM}Unknown Member (ID: {schedule.MemberId}) (Schedule ID: {schedule.ScheduleId}){TableColors.RESET}")
# Let user select multiple members
print(f"\n{TableColors.SUCCESS}Multiple Selection Options:{TableColors.RESET}")
print(f"{TableColors.CYAN}Single:{TableColors.RESET} Enter a number (e.g., {TableColors.YELLOW}3{TableColors.RESET})")
print(f"{TableColors.CYAN}Multiple:{TableColors.RESET} Enter numbers separated by commas (e.g., {TableColors.YELLOW}1,3,5{TableColors.RESET})")
print(f"{TableColors.CYAN}Range:{TableColors.RESET} Enter a range (e.g., {TableColors.YELLOW}1-4{TableColors.RESET})")
print(f"{TableColors.CYAN}All:{TableColors.RESET} Enter {TableColors.YELLOW}all{TableColors.RESET} to select everyone")
# Let user select member
try:
choice = input(f"\nSelect member to accept (1-{len(pending_schedules)}): ").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
choice = input(f"\n{TableColors.INPUT_BOX}┌─ Select members to accept ─┐{TableColors.RESET}\n{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice:
print(f"{TableColors.ERROR}❌ No selection made{TableColors.RESET}")
return
member_index = int(choice) - 1
if member_index < 0 or member_index >= len(pending_schedules):
print("❌ Invalid selection")
selected_indices = []
if choice.lower() == 'all':
selected_indices = list(range(len(pending_schedules)))
elif '-' in choice and ',' not in choice:
# Handle range (e.g., "1-4")
try:
start, end = choice.split('-')
start_idx = int(start.strip()) - 1
end_idx = int(end.strip()) - 1
if start_idx < 0 or end_idx >= len(pending_schedules) or start_idx > end_idx:
print(f"{TableColors.ERROR}❌ Invalid range{TableColors.RESET}")
return
selected_indices = list(range(start_idx, end_idx + 1))
except (ValueError, IndexError):
print(f"{TableColors.ERROR}❌ Invalid range format{TableColors.RESET}")
return
else:
# Handle single number or comma-separated list
try:
numbers = [int(x.strip()) for x in choice.split(',')]
for num in numbers:
idx = num - 1
if idx < 0 or idx >= len(pending_schedules):
print(f"{TableColors.ERROR}❌ Invalid selection: {num}{TableColors.RESET}")
return
if idx not in selected_indices:
selected_indices.append(idx)
except ValueError:
print(f"{TableColors.ERROR}❌ Invalid input format{TableColors.RESET}")
return
if not selected_indices:
print(f"{TableColors.ERROR}❌ No valid selections{TableColors.RESET}")
return
selected_schedule = pending_schedules[member_index]
schedules_to_accept = [pending_schedules[i] for i in selected_indices]
except (KeyboardInterrupt, EOFError):
print("\n🛑 Cancelled")
print(f"\n{TableColors.WARNING}🛑 Cancelled{TableColors.RESET}")
return
# Accept the selected schedule
schedule_to_accept = selected_schedule
# Direct mode with schedule ID
# Direct mode with schedule ID (single schedule)
elif hasattr(args, 'schedule_id') and args.schedule_id:
schedule_to_accept = cli.schedule_repo.get_by_id(args.schedule_id)
if not schedule_to_accept:
print(f"❌ Schedule with ID {args.schedule_id} not found")
print(f"{TableColors.ERROR}❌ Schedule with ID {args.schedule_id} not found{TableColors.RESET}")
return
schedules_to_accept = [schedule_to_accept]
else:
print("❌ Either --date or schedule_id must be provided")
print(f"{TableColors.ERROR}❌ Either --date or schedule_id must be provided{TableColors.RESET}")
return
# Common validation and acceptance logic
if schedule_to_accept.Status == ScheduleStatus.ACCEPTED.value:
print(f"⚠️ Schedule {schedule_to_accept.ScheduleId} is already accepted")
# Process multiple schedules
successful_accepts = []
failed_accepts = []
print(f"\n{TableColors.SUCCESS}Processing {len(schedules_to_accept)} schedule(s)...{TableColors.RESET}")
for schedule in schedules_to_accept:
# Validation for each schedule
if schedule.Status == ScheduleStatus.ACCEPTED.value:
failed_accepts.append((schedule, f"already accepted"))
continue
if schedule.Status == ScheduleStatus.DECLINED.value:
failed_accepts.append((schedule, f"previously declined"))
continue
# Get member and service info for display
member = cli.member_repo.get_by_id(schedule.MemberId)
service = cli.service_repo.get_by_id(schedule.ServiceId)
service_type = None
if service:
service_type = cli.service_type_repo.get_by_id(service.ServiceTypeId)
try:
# Mark the schedule as accepted
cli.schedule_repo.mark_accepted(schedule.ScheduleId)
# Update member's acceptance timestamp
cli.member_repo.set_last_accepted(schedule.MemberId)
successful_accepts.append((schedule, member, service, service_type))
except Exception as e:
failed_accepts.append((schedule, f"database error: {e}"))
# Display results
if successful_accepts:
print(f"\n{TableColors.SUCCESS}✅ Successfully accepted {len(successful_accepts)} schedule(s):{TableColors.RESET}")
for schedule, member, service, service_type in successful_accepts:
member_name = f"{member.FirstName} {member.LastName}" if member else f"Member ID {schedule.MemberId}"
service_info = f"{service_type.TypeName} on {service.ServiceDate}" if service and service_type else f"Service ID {schedule.ServiceId}"
print(f" {TableColors.CYAN}{TableColors.RESET} {TableColors.BOLD}{member_name}{TableColors.RESET} - {service_info}")
if failed_accepts:
print(f"\n{TableColors.WARNING}⚠️ Could not accept {len(failed_accepts)} schedule(s):{TableColors.RESET}")
for schedule, reason in failed_accepts:
member = cli.member_repo.get_by_id(schedule.MemberId)
member_name = f"{member.FirstName} {member.LastName}" if member else f"Member ID {schedule.MemberId}"
print(f" {TableColors.YELLOW}{TableColors.RESET} {TableColors.BOLD}{member_name}{TableColors.RESET} - {reason}")
if not successful_accepts and not failed_accepts:
print(f"{TableColors.DIM}No schedules processed{TableColors.RESET}")
def cmd_schedules_remove(cli: "NimbusFlowCLI", args) -> None:
"""Remove scheduled members and move them back to the front of the queue."""
# Interactive mode with date parameter
if hasattr(args, 'date') and args.date:
try:
target_date = date.fromisoformat(args.date)
except ValueError:
print(f"{TableColors.ERROR}❌ Invalid date format '{args.date}'. Use YYYY-MM-DD{TableColors.RESET}")
return
# Find services for the specified date
all_services = cli.service_repo.list_all()
services_on_date = [s for s in all_services if s.ServiceDate == target_date]
if not services_on_date:
print(f"{TableColors.ERROR}❌ No services found for {args.date}{TableColors.RESET}")
return
# Get service types for display
service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()}
# Show available services for the date
print(f"\n{TableColors.HEADER}Services available on {args.date}:{TableColors.RESET}")
print(f"{TableColors.BORDER}{'' * 40}{TableColors.RESET}")
for i, service in enumerate(services_on_date, 1):
type_name = service_type_map.get(service.ServiceTypeId, "Unknown")
print(f"{TableColors.CYAN}{i}.{TableColors.RESET} {type_name} {TableColors.DIM}(Service ID: {service.ServiceId}){TableColors.RESET}")
# Let user select service
try:
choice = input(f"\n{TableColors.INPUT_BOX}┌─ Select service (1-{len(services_on_date)}) ─┐{TableColors.RESET}\n{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice or not choice.isdigit():
print(f"{TableColors.ERROR}❌ Invalid selection{TableColors.RESET}")
return
service_index = int(choice) - 1
if service_index < 0 or service_index >= len(services_on_date):
print(f"{TableColors.ERROR}❌ Invalid selection{TableColors.RESET}")
return
selected_service = services_on_date[service_index]
except (KeyboardInterrupt, EOFError):
print(f"\n{TableColors.WARNING}🛑 Cancelled{TableColors.RESET}")
return
# Find all schedules for this service (not just pending)
all_schedules = cli.schedule_repo.list_all()
service_schedules = [
s for s in all_schedules
if s.ServiceId == selected_service.ServiceId
]
if not service_schedules:
service_type_name = service_type_map.get(selected_service.ServiceTypeId, "Unknown")
print(f"{TableColors.WARNING}⚠️ No schedules found for {service_type_name} on {args.date}{TableColors.RESET}")
return
# Get member info for display
member_map = {m.MemberId: m for m in cli.member_repo.list_all()}
# Show available members to remove
print(f"\n{TableColors.HEADER}Members scheduled for {service_type_map.get(selected_service.ServiceTypeId, 'Unknown')} on {args.date}:{TableColors.RESET}")
print(f"{TableColors.BORDER}{'' * 70}{TableColors.RESET}")
for i, schedule in enumerate(service_schedules, 1):
member = member_map.get(schedule.MemberId)
status_color = TableColors.SUCCESS if schedule.Status == "accepted" else TableColors.WARNING if schedule.Status == "pending" else TableColors.ERROR
status_display = f"{status_color}{schedule.Status.upper()}{TableColors.RESET}"
if member:
print(f"{TableColors.CYAN}{i:2d}.{TableColors.RESET} {TableColors.BOLD}{member.FirstName} {member.LastName}{TableColors.RESET} {TableColors.DIM}({status_display}, ID: {schedule.ScheduleId}){TableColors.RESET}")
else:
print(f"{TableColors.CYAN}{i:2d}.{TableColors.RESET} {TableColors.DIM}Unknown Member (ID: {schedule.MemberId}) ({status_display}, ID: {schedule.ScheduleId}){TableColors.RESET}")
# Let user select multiple members to remove
print(f"\n{TableColors.SUCCESS}Multiple Selection Options:{TableColors.RESET}")
print(f"{TableColors.CYAN}Single:{TableColors.RESET} Enter a number (e.g., {TableColors.YELLOW}3{TableColors.RESET})")
print(f"{TableColors.CYAN}Multiple:{TableColors.RESET} Enter numbers separated by commas (e.g., {TableColors.YELLOW}1,3,5{TableColors.RESET})")
print(f"{TableColors.CYAN}Range:{TableColors.RESET} Enter a range (e.g., {TableColors.YELLOW}1-4{TableColors.RESET})")
print(f"{TableColors.CYAN}All:{TableColors.RESET} Enter {TableColors.YELLOW}all{TableColors.RESET} to remove everyone")
try:
choice = input(f"\n{TableColors.INPUT_BOX}┌─ Select members to remove ─┐{TableColors.RESET}\n{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice:
print(f"{TableColors.ERROR}❌ No selection made{TableColors.RESET}")
return
selected_indices = []
if choice.lower() == 'all':
selected_indices = list(range(len(service_schedules)))
elif '-' in choice and ',' not in choice:
# Handle range (e.g., "1-4")
try:
start, end = choice.split('-')
start_idx = int(start.strip()) - 1
end_idx = int(end.strip()) - 1
if start_idx < 0 or end_idx >= len(service_schedules) or start_idx > end_idx:
print(f"{TableColors.ERROR}❌ Invalid range{TableColors.RESET}")
return
selected_indices = list(range(start_idx, end_idx + 1))
except (ValueError, IndexError):
print(f"{TableColors.ERROR}❌ Invalid range format{TableColors.RESET}")
return
else:
# Handle single number or comma-separated list
try:
numbers = [int(x.strip()) for x in choice.split(',')]
for num in numbers:
idx = num - 1
if idx < 0 or idx >= len(service_schedules):
print(f"{TableColors.ERROR}❌ Invalid selection: {num}{TableColors.RESET}")
return
if idx not in selected_indices:
selected_indices.append(idx)
except ValueError:
print(f"{TableColors.ERROR}❌ Invalid input format{TableColors.RESET}")
return
if not selected_indices:
print(f"{TableColors.ERROR}❌ No valid selections{TableColors.RESET}")
return
schedules_to_remove = [service_schedules[i] for i in selected_indices]
except (KeyboardInterrupt, EOFError):
print(f"\n{TableColors.WARNING}🛑 Cancelled{TableColors.RESET}")
return
# Direct mode with schedule ID (single schedule)
elif hasattr(args, 'schedule_id') and args.schedule_id:
schedule_to_remove = cli.schedule_repo.get_by_id(args.schedule_id)
if not schedule_to_remove:
print(f"{TableColors.ERROR}❌ Schedule with ID {args.schedule_id} not found{TableColors.RESET}")
return
schedules_to_remove = [schedule_to_remove]
else:
print(f"{TableColors.ERROR}❌ Either --date or schedule_id must be provided{TableColors.RESET}")
return
if schedule_to_accept.Status == ScheduleStatus.DECLINED.value:
print(f"⚠️ Schedule {schedule_to_accept.ScheduleId} was previously declined")
return
# Process schedule removals
successful_removals = []
failed_removals = []
# Get member and service info for display
member = cli.member_repo.get_by_id(schedule_to_accept.MemberId)
service = cli.service_repo.get_by_id(schedule_to_accept.ServiceId)
service_type = None
if service:
service_type = cli.service_type_repo.get_by_id(service.ServiceTypeId)
print(f"\n{TableColors.WARNING}Processing {len(schedules_to_remove)} schedule removal(s)...{TableColors.RESET}")
# Mark the schedule as accepted
cli.schedule_repo.mark_accepted(schedule_to_accept.ScheduleId)
for schedule in schedules_to_remove:
# Get member and service info for display
member = cli.member_repo.get_by_id(schedule.MemberId)
service = cli.service_repo.get_by_id(schedule.ServiceId)
service_type = None
if service:
service_type = cli.service_type_repo.get_by_id(service.ServiceTypeId)
try:
# Delete the schedule
was_deleted = cli.schedule_repo.delete_schedule(schedule.ScheduleId)
if was_deleted:
# Move member back to front of round robin queue
cli.member_repo.reset_to_queue_front(schedule.MemberId)
successful_removals.append((schedule, member, service, service_type))
else:
failed_removals.append((schedule, "schedule not found"))
except Exception as e:
failed_removals.append((schedule, f"database error: {e}"))
# Update member's acceptance timestamp
cli.member_repo.set_last_accepted(schedule_to_accept.MemberId)
# Display results
if successful_removals:
print(f"\n{TableColors.SUCCESS}✅ Successfully removed {len(successful_removals)} schedule(s):{TableColors.RESET}")
print(f"{TableColors.SUCCESS} Members moved to front of round robin queue:{TableColors.RESET}")
for schedule, member, service, service_type in successful_removals:
member_name = f"{member.FirstName} {member.LastName}" if member else f"Member ID {schedule.MemberId}"
service_info = f"{service_type.TypeName} on {service.ServiceDate}" if service and service_type else f"Service ID {schedule.ServiceId}"
print(f" {TableColors.CYAN}{TableColors.RESET} {TableColors.BOLD}{member_name}{TableColors.RESET} - {service_info}")
print(f"✅ Schedule {schedule_to_accept.ScheduleId} accepted successfully!")
if member and service and service_type:
print(f" Member: {member.FirstName} {member.LastName}")
print(f" Service: {service_type.TypeName} on {service.ServiceDate}")
if failed_removals:
print(f"\n{TableColors.ERROR}❌ Could not remove {len(failed_removals)} schedule(s):{TableColors.RESET}")
for schedule, reason in failed_removals:
member = cli.member_repo.get_by_id(schedule.MemberId)
member_name = f"{member.FirstName} {member.LastName}" if member else f"Member ID {schedule.MemberId}"
print(f" {TableColors.YELLOW}{TableColors.RESET} {TableColors.BOLD}{member_name}{TableColors.RESET} - {reason}")
if not successful_removals and not failed_removals:
print(f"{TableColors.DIM}No schedules processed{TableColors.RESET}")
def cmd_schedules_decline(cli: "NimbusFlowCLI", args) -> None:
@@ -257,15 +520,18 @@ def cmd_schedules_decline(cli: "NimbusFlowCLI", args) -> None:
service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()}
# Show available services for the date
print(f"\n📅 Services available on {args.date}:")
print("-" * 40)
print(f"\n{TableColors.HEADER}Services available on {args.date}{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 50 + f"{TableColors.RESET}")
print()
for i, service in enumerate(services_on_date, 1):
type_name = service_type_map.get(service.ServiceTypeId, "Unknown")
print(f"{i}. {type_name} (Service ID: {service.ServiceId})")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {type_name}")
print()
# Let user select service
try:
choice = input(f"\nSelect service (1-{len(services_on_date)}): ").strip()
print(f"\n{TableColors.INPUT_BOX}┌─ Select service (1-{len(services_on_date)}) ─┐{TableColors.RESET}")
choice = input(f"{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
return
@@ -405,15 +671,18 @@ def cmd_schedules_schedule(cli: "NimbusFlowCLI", args) -> None:
service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()}
# Show available services for the date
print(f"\n📅 Services available on {args.date}:")
print("-" * 50)
print(f"\n{TableColors.HEADER}Services available on {args.date}{TableColors.RESET}")
print(f"{TableColors.BORDER}" * 50 + f"{TableColors.RESET}")
print()
for i, svc in enumerate(services_on_date, 1):
type_name = service_type_map.get(svc.ServiceTypeId, "Unknown")
print(f"{i}. {type_name} (Service ID: {svc.ServiceId})")
print(f" {TableColors.CYAN}{i}.{TableColors.RESET} {type_name}")
print()
# Let user select service
try:
choice = input(f"\nSelect service (1-{len(services_on_date)}): ").strip()
print(f"\n{TableColors.INPUT_BOX}┌─ Select service (1-{len(services_on_date)}) ─┐{TableColors.RESET}")
choice = input(f"{TableColors.INPUT_BOX}└─> {TableColors.RESET}").strip()
if not choice or not choice.isdigit():
print("❌ Invalid selection")
return
@@ -577,6 +846,11 @@ def setup_schedules_parser(subparsers) -> None:
schedules_decline_parser.add_argument("--date", type=str, help="Interactive mode: select service and member by date (YYYY-MM-DD)")
schedules_decline_parser.add_argument("--reason", type=str, help="Reason for declining")
# schedules remove
schedules_remove_parser = schedules_subparsers.add_parser("remove", help="Remove scheduled members and move them to front of queue")
schedules_remove_parser.add_argument("schedule_id", type=int, nargs="?", help="Schedule ID to remove (optional if using --date)")
schedules_remove_parser.add_argument("--date", type=str, help="Interactive mode: select service and members by date (YYYY-MM-DD)")
# schedules schedule
schedules_schedule_parser = schedules_subparsers.add_parser("schedule", help="Schedule next member for a service (cycles through eligible members)")
schedules_schedule_parser.add_argument("service_id", type=int, nargs="?", help="Service ID to schedule for (optional if using --date)")