From 94900b19f7a252a45049d7e4b7ef8b698687e7ec Mon Sep 17 00:00:00 2001 From: Giovani Date: Thu, 28 Aug 2025 16:21:39 -0400 Subject: [PATCH] feat(cli): improve member design and usability --- backend/cli/commands/__init__.py | 4 +- backend/cli/commands/schedules.py | 376 ++++++++++++++++++++++++++---- backend/cli/interactive.py | 140 +++++++---- backend/cli/main.py | 6 +- backend/cli/utils.py | 1 + backend/repositories/member.py | 18 +- backend/repositories/schedule.py | 14 +- 7 files changed, 453 insertions(+), 106 deletions(-) diff --git a/backend/cli/commands/__init__.py b/backend/cli/commands/__init__.py index e17a03b..15406bd 100644 --- a/backend/cli/commands/__init__.py +++ b/backend/cli/commands/__init__.py @@ -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", ] \ No newline at end of file diff --git a/backend/cli/commands/schedules.py b/backend/cli/commands/schedules.py index 6778dcc..4ef0e50 100644 --- a/backend/cli/commands/schedules.py +++ b/backend/cli/commands/schedules.py @@ -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)") diff --git a/backend/cli/interactive.py b/backend/cli/interactive.py index b41ddcd..6717162 100644 --- a/backend/cli/interactive.py +++ b/backend/cli/interactive.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from .commands import ( cmd_members_list, cmd_members_show, - cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept, cmd_schedules_decline, cmd_schedules_schedule, + cmd_schedules_list, cmd_schedules_show, cmd_schedules_accept, cmd_schedules_decline, cmd_schedules_remove, cmd_schedules_schedule, cmd_services_list, ) @@ -173,15 +173,12 @@ def display_schedules_menu(): print(f"\n{Colors.HEADER}Schedules{Colors.RESET}") print(f"{Colors.GREY}─" * 50 + f"{Colors.RESET}") print() - print(f" {Colors.CYAN}1.{Colors.RESET} List all schedules") - print(f" {Colors.CYAN}2.{Colors.RESET} List pending schedules") - print(f" {Colors.CYAN}3.{Colors.RESET} List accepted schedules") - print(f" {Colors.CYAN}4.{Colors.RESET} List declined schedules") - print(f" {Colors.CYAN}5.{Colors.RESET} Show schedule details") - print(f" {Colors.CYAN}6.{Colors.RESET} {Colors.GREEN}Accept a schedule{Colors.RESET}") - print(f" {Colors.CYAN}7.{Colors.RESET} {Colors.RED}Decline a schedule{Colors.RESET}") - print(f" {Colors.CYAN}8.{Colors.RESET} {Colors.YELLOW}Schedule next member for service{Colors.RESET}") - print(f" {Colors.CYAN}9.{Colors.RESET} {Colors.DIM}Back to main menu{Colors.RESET}") + print(f" {Colors.CYAN}1.{Colors.RESET} Browse schedules") + print(f" {Colors.CYAN}2.{Colors.RESET} {Colors.GREEN}Accept a schedule{Colors.RESET}") + print(f" {Colors.CYAN}3.{Colors.RESET} {Colors.RED}Decline a schedule{Colors.RESET}") + print(f" {Colors.CYAN}4.{Colors.RESET} {Colors.ERROR}Remove scheduled members{Colors.RESET}") + print(f" {Colors.CYAN}5.{Colors.RESET} {Colors.YELLOW}Schedule next member for service{Colors.RESET}") + print(f" {Colors.CYAN}6.{Colors.RESET} {Colors.DIM}Back to main menu{Colors.RESET}") print() @@ -251,6 +248,25 @@ def get_date_input(prompt: str = "Enter date (YYYY-MM-DD)") -> str: return "" +def get_date_input_optional(prompt: str = "Enter date (YYYY-MM-DD)") -> str: + """Get optional date input from user (allows empty input).""" + while True: + try: + print(create_simple_input_box(prompt)) + date_str = input(f"{Colors.INPUT_BOX}└─> {Colors.RESET}").strip() + if not date_str: + return "" # Allow empty input + # Basic date format validation + if len(date_str) == 10 and date_str.count('-') == 2: + parts = date_str.split('-') + if len(parts[0]) == 4 and len(parts[1]) == 2 and len(parts[2]) == 2: + return date_str + print(f"{Colors.ERROR}Please use format YYYY-MM-DD (e.g., 2025-09-07){Colors.RESET}") + except (KeyboardInterrupt, EOFError): + print(f"\n{Colors.WARNING}Operation cancelled{Colors.RESET}") + return "" + + class MockArgs: """Mock args object for interactive commands.""" def __init__(self, **kwargs): @@ -306,73 +322,105 @@ def handle_schedules_menu(cli: "NimbusFlowCLI"): while True: clear_screen() display_schedules_menu() - choice = get_user_choice(9) + choice = get_user_choice(6) if choice == 1: # List all schedules clear_screen() - print(f"{Colors.SUCCESS}Listing all schedules...{Colors.RESET}\n") - cmd_schedules_list(cli, MockArgs(service_id=None, status=None)) - input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}") - elif choice == 2: # List pending schedules - clear_screen() - print(f"{Colors.WARNING}Listing pending schedules...{Colors.RESET}\n") - cmd_schedules_list(cli, MockArgs(service_id=None, status="pending")) - input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}") - - elif choice == 3: # List accepted schedules - clear_screen() - print(f"{Colors.SUCCESS}Listing accepted schedules...{Colors.RESET}\n") - cmd_schedules_list(cli, MockArgs(service_id=None, status="accepted")) - input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}") - - elif choice == 4: # List declined schedules - clear_screen() - print(f"{Colors.ERROR}Listing declined schedules...{Colors.RESET}\n") - cmd_schedules_list(cli, MockArgs(service_id=None, status="declined")) - input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}") - - elif choice == 5: # Show schedule details - clear_screen() - schedule_id = get_text_input("Enter schedule ID", True) - if schedule_id.isdigit(): + # Get date filter + date = get_date_input_optional("Enter date to filter schedules (or press Enter to skip)") + if not date: clear_screen() - print(f"{Colors.SUCCESS}Showing details for schedule {schedule_id}...{Colors.RESET}\n") - cmd_schedules_show(cli, MockArgs(schedule_id=int(schedule_id))) + cmd_schedules_list(cli, MockArgs(service_id=None, status=None)) else: - print(f"{Colors.ERROR}Invalid schedule ID{Colors.RESET}") + # Find services for the date + try: + from datetime import date as date_type + target_date = date_type.fromisoformat(date) + all_services = cli.service_repo.list_all() + services_on_date = [s for s in all_services if s.ServiceDate == target_date] + except ValueError: + clear_screen() + print(f"{Colors.ERROR}Invalid date format. Please use YYYY-MM-DD format.{Colors.RESET}") + services_on_date = [] + + if not services_on_date: + clear_screen() + print(f"{Colors.ERROR}No services found for {date}{Colors.RESET}") + else: + clear_screen() + + # Show available services for selection + service_type_map = {st.ServiceTypeId: st.TypeName for st in cli.service_type_repo.list_all()} + + print(f"\n{Colors.HEADER}Services available on {date}{Colors.RESET}") + print(f"{Colors.GREY}─" * 50 + f"{Colors.RESET}") + print() + for i, service in enumerate(services_on_date, 1): + type_name = service_type_map.get(service.ServiceTypeId, "Unknown") + print(f" {Colors.CYAN}{i}.{Colors.RESET} {type_name}") + print() + + # Get service selection + try: + print(create_simple_input_box(f"Select service (1-{len(services_on_date)}) or press Enter to show all")) + choice_input = input(f"{Colors.INPUT_BOX}└─> {Colors.RESET}").strip() + + if not choice_input: + # Empty input - show all services for this date + clear_screen() + cmd_schedules_list(cli, MockArgs(service_id=None, status=None, date=date)) + elif not choice_input.isdigit(): + print(f"{Colors.ERROR}Invalid selection{Colors.RESET}") + else: + service_choice = int(choice_input) + + if service_choice < 1 or service_choice > len(services_on_date): + print(f"{Colors.ERROR}Please enter a number between 1 and {len(services_on_date)}{Colors.RESET}") + else: + clear_screen() + selected_service = services_on_date[service_choice - 1] + cmd_schedules_list(cli, MockArgs(service_id=selected_service.ServiceId, status=None)) + except (KeyboardInterrupt, EOFError): + print(f"\n{Colors.WARNING}Operation cancelled{Colors.RESET}") + input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}") - elif choice == 6: # Accept schedule + elif choice == 2: # Accept schedule clear_screen() date = get_date_input("Enter date for interactive accept") if date: clear_screen() - print(f"{Colors.SUCCESS}Starting interactive accept for {date}...{Colors.RESET}\n") cmd_schedules_accept(cli, MockArgs(date=date, schedule_id=None)) input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}") - elif choice == 7: # Decline schedule + elif choice == 3: # Decline schedule clear_screen() date = get_date_input("Enter date for interactive decline") if date: clear_screen() reason = get_text_input("Enter decline reason (optional)", False) clear_screen() - print(f"{Colors.ERROR}Starting interactive decline for {date}...{Colors.RESET}\n") cmd_schedules_decline(cli, MockArgs(date=date, schedule_id=None, reason=reason)) input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}") - elif choice == 8: # Schedule next member + elif choice == 4: # Remove scheduled members + clear_screen() + date = get_date_input("Enter date to remove schedules for") + if date: + clear_screen() + cmd_schedules_remove(cli, MockArgs(date=date, schedule_id=None)) + input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}") + + elif choice == 5: # Schedule next member clear_screen() date = get_date_input("Enter date to schedule for") if date: clear_screen() - print(f"{Colors.WARNING}Starting scheduling for {date}...{Colors.RESET}\n") cmd_schedules_schedule(cli, MockArgs(service_id=None, date=date, classifications=None)) input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}") - elif choice == 9: # Back to main menu + elif choice == 6: # Back to main menu break diff --git a/backend/cli/main.py b/backend/cli/main.py index 2b04c9a..762a390 100644 --- a/backend/cli/main.py +++ b/backend/cli/main.py @@ -11,7 +11,7 @@ from .commands import ( 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, ) @@ -99,10 +99,12 @@ def main(): cmd_schedules_accept(cli, args) elif args.schedules_action == "decline": cmd_schedules_decline(cli, args) + elif args.schedules_action == "remove": + cmd_schedules_remove(cli, args) elif args.schedules_action == "schedule": cmd_schedules_schedule(cli, args) else: - print(f"{Colors.ERROR}❌ Unknown schedules action. Use 'list', 'show', 'accept', 'decline', or 'schedule'{Colors.RESET}") + print(f"{Colors.ERROR}❌ Unknown schedules action. Use 'list', 'show', 'accept', 'decline', 'remove', or 'schedule'{Colors.RESET}") elif args.command == "services": if args.services_action == "list": diff --git a/backend/cli/utils.py b/backend/cli/utils.py index 28c4860..db01997 100644 --- a/backend/cli/utils.py +++ b/backend/cli/utils.py @@ -25,6 +25,7 @@ class TableColors: ERROR = '\033[1m\033[91m' # Bold Red WARNING = '\033[1m\033[93m' # Bold Yellow BORDER = '\033[90m' # Grey + INPUT_BOX = '\033[90m' # Grey (for input styling) def format_member_row(member, classification_name: Optional[str] = None) -> str: diff --git a/backend/repositories/member.py b/backend/repositories/member.py index d94c917..2142fe6 100644 --- a/backend/repositories/member.py +++ b/backend/repositories/member.py @@ -234,4 +234,20 @@ class MemberRepository(BaseRepository[MemberModel]): DeclineStreak = COALESCE(DeclineStreak, 0) + 1 WHERE {self._PK} = ? """ - self.db.execute(sql, (decline_date, member_id)) \ No newline at end of file + self.db.execute(sql, (decline_date, member_id)) + + def reset_to_queue_front(self, member_id: int) -> None: + """ + Reset member timestamps to move them to the front of the round robin queue. + This sets LastScheduledAt and LastAcceptedAt to far past values, effectively + making them the highest priority for scheduling. + """ + sql = f""" + UPDATE {self._TABLE} + SET LastScheduledAt = '1970-01-01 00:00:00', + LastAcceptedAt = '1970-01-01 00:00:00', + LastDeclinedAt = NULL, + DeclineStreak = 0 + WHERE {self._PK} = ? + """ + self.db.execute(sql, (member_id,)) \ No newline at end of file diff --git a/backend/repositories/schedule.py b/backend/repositories/schedule.py index 60c0be2..555c573 100644 --- a/backend/repositories/schedule.py +++ b/backend/repositories/schedule.py @@ -257,8 +257,14 @@ class ScheduleRepository(BaseRepository[ScheduleModel]): """ rows = self.db.fetchall(sql, (service_id, ScheduleStatus.PENDING.value)) return [ScheduleModel.from_row(r) for r in rows] - - def delete(self, schedule_id: int) -> None: - """Hard‑delete a schedule row (use with caution).""" + + def delete_schedule(self, schedule_id: int) -> bool: + """ + Delete a schedule by ID. + + Returns: + bool: True if a schedule was deleted, False if not found + """ sql = f"DELETE FROM {self._TABLE} WHERE {self._PK} = ?" - self.db.execute(sql, (schedule_id,)) \ No newline at end of file + cursor = self.db.execute(sql, (schedule_id,)) + return cursor.rowcount > 0 \ No newline at end of file