diff --git a/.python-version b/.python-version index 0a59033..0308e66 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.9.10 +3.910 diff --git a/App/controllers/__init__.py b/App/controllers/__init__.py index 0cb8fd1..e46582b 100644 --- a/App/controllers/__init__.py +++ b/App/controllers/__init__.py @@ -2,4 +2,5 @@ from .auth import * from .initialize import * from .admin import * -from .staff import * +from .staff import * +from .schedule_controller import ScheduleController diff --git a/App/controllers/admin.py b/App/controllers/admin.py index aa2d9ca..964ee72 100644 --- a/App/controllers/admin.py +++ b/App/controllers/admin.py @@ -1,58 +1,107 @@ -from App.models import Shift +from App.models import Shift, Schedule, Staff, ShiftSwapRequest from App.database import db -from datetime import datetime +from datetime import datetime, timedelta from App.controllers.user import get_user +from sqlalchemy import func -from App.models import Shift, Schedule -from App.database import db -from datetime import datetime -from App.controllers.user import get_user - -def create_schedule(admin_id, scheduleName): #Not sure why this was missing - admin = get_user(admin_id) - if not admin or admin.role != "admin": - raise PermissionError("Only admins can create schedules") - +def create_schedule(admin_id, scheduleName): new_schedule = Schedule( created_by=admin_id, name=scheduleName, created_at=datetime.utcnow() ) - db.session.add(new_schedule) db.session.commit() - return new_schedule def schedule_shift(admin_id, staff_id, schedule_id, start_time, end_time): - admin = get_user(admin_id) staff = get_user(staff_id) - schedule = db.session.get(Schedule, schedule_id) - - if not admin or admin.role != "admin": - raise PermissionError("Only admins can schedule shifts") if not staff or staff.role != "staff": - raise ValueError("Invalid staff member") + raise PermissionError("Only staff can be assigned to a shift.") if not schedule: raise ValueError("Invalid schedule ID") - new_shift = Shift( staff_id=staff_id, schedule_id=schedule_id, start_time=start_time, end_time=end_time ) - db.session.add(new_shift) db.session.commit() - return new_shift - def get_shift_report(admin_id): admin = get_user(admin_id) if not admin or admin.role != "admin": - raise PermissionError("Only admins can view shift reports") + raise PermissionError("Only admin can view shift report") + return [shift.get_json() for shift in Shift.query.order_by(Shift.start_time).all()] + +def get_total_staff_count(): + """Count total number of staff members.""" + return db.session.query(func.count(Staff.id)).scalar() or 0 + +def get_shifts_this_week(): + """Count shifts scheduled for this week.""" + today = datetime.utcnow() + week_start = today - timedelta(days=today.weekday()) + week_end = week_start + timedelta(days=6, hours=23, minutes=59, seconds=59) + + return db.session.query(func.count(Shift.id)).filter( + Shift.start_time >= week_start, + Shift.start_time <= week_end + ).scalar() or 0 - return [shift.get_json() for shift in Shift.query.order_by(Shift.start_time).all()] \ No newline at end of file +def get_pending_swap_requests(): + """Get all pending shift swap requests.""" + requests = ShiftSwapRequest.query.filter_by(status="pending").all() + return [req.get_json() for req in requests] + +def get_staff_attendance(): + """Get attendance data for all staff (with clock in/out times and hours).""" + staff_members = Staff.query.all() + attendance_data = [] + + for staff in staff_members: + shifts = Shift.query.filter_by(staff_id=staff.id).all() + + for shift in shifts: + hours = 0 + status = "Absent" + + if shift.clock_in and shift.clock_out: + delta = shift.clock_out - shift.clock_in + hours = round(delta.total_seconds() / 3600, 1) + status = "Present" + elif shift.clock_in and not shift.clock_out: + status = "Clocked In" + + attendance_data.append({ + "staff_name": staff.username, + "staff_id": staff.id, + "clock_in": shift.clock_in.strftime("%I:%M %p") if shift.clock_in else "—", + "clock_out": shift.clock_out.strftime("%I:%M %p") if shift.clock_out else "—", + "hours": hours, + "status": status, + "shift_id": shift.id + }) + + return attendance_data + +def approve_swap_request(request_id): + """Approve a shift swap request.""" + swap_req = db.session.get(ShiftSwapRequest, request_id) + if not swap_req: + raise ValueError("Swap request not found") + swap_req.status = "approved" + db.session.commit() + return swap_req + +def deny_swap_request(request_id): + """Deny a shift swap request.""" + swap_req = db.session.get(ShiftSwapRequest, request_id) + if not swap_req: + raise ValueError("Swap request not found") + swap_req.status = "denied" + db.session.commit() + return swap_req \ No newline at end of file diff --git a/App/controllers/auth.py b/App/controllers/auth.py index e46a40f..e8e7611 100644 --- a/App/controllers/auth.py +++ b/App/controllers/auth.py @@ -1,80 +1,24 @@ -from flask_jwt_extended import ( - create_access_token, jwt_required, JWTManager, - get_jwt_identity, verify_jwt_in_request -) from App.models import User from App.database import db def login(username, password): - result = db.session.execute(db.select(User).filter_by(username=username)) - user = result.scalar_one_or_none() - if user and user.check_password(password): - # Store ONLY the user id as a string in JWT 'sub' - return create_access_token(identity=str(user.id)) - return None + result = db.session.execute(db.select(User).filter_by(username=username)) + user = result.scalar_one_or_none() + if user and user.check_password(password): + return user # Return user object directly, no JWT + return None def loginCLI(username, password): result = db.session.execute(db.select(User).filter_by(username=username)) user = result.scalar_one_or_none() - if user and user.check_password(password): - - if user.active_token: - return {"message": "User already logged in", "token": user.active_token} - - token = create_access_token(identity=str(user.id)) - user.active_token = token - db.session.commit() - return {"message": "Login successful", "token": token} - + return {"message": "Login successful", "user_id": user.id} return {"message": "Invalid username or password"} def logout(username): + # No authentication/session to clear, just a stub result = db.session.execute(db.select(User).filter_by(username=username)) user = result.scalar_one_or_none() - if not user: return {"message": "User not found"} - - if not user.active_token: - return {"message": f"User {username} is not logged in"} - - user.active_token = None - db.session.commit() - return {"message": f"User {username} logged out successfully"} - -def setup_jwt(app): - jwt = JWTManager(app) - - # Always store a string user id in the JWT identity (sub) - @jwt.user_identity_loader - def user_identity_lookup(identity): - user_id = getattr(identity, "id", identity) - return str(user_id) if user_id is not None else None - - @jwt.user_lookup_loader - def user_lookup_callback(_jwt_header, jwt_data): - identity = jwt_data["sub"] - try: - user_id = int(identity) - except (TypeError, ValueError): - return None - return db.session.get(User, user_id) - - return jwt - -# Context processor to make 'is_authenticated' available to all templates -def add_auth_context(app): - @app.context_processor - def inject_user(): - try: - verify_jwt_in_request() - identity = get_jwt_identity() - user_id = int(identity) if identity is not None else None - current_user = db.session.get(User, user_id) if user_id is not None else None - is_authenticated = current_user is not None - except Exception as e: - print(e) - is_authenticated = False - current_user = None - return dict(is_authenticated=is_authenticated, current_user=current_user) + return {"message": f"User {username} logged out (no session)"} diff --git a/App/controllers/initialize.py b/App/controllers/initialize.py index 49907b2..694456b 100644 --- a/App/controllers/initialize.py +++ b/App/controllers/initialize.py @@ -1,30 +1,52 @@ from .user import create_user from App.database import db - +from App.models import Schedule, Shift +from datetime import datetime, timedelta def initialize(): db.drop_all() db.create_all() - create_user('bob', 'bobpass', 'admin') - create_user('jane', 'janepass', 'staff') - create_user('alice', 'alicepass', 'staff') - create_user('tim', 'timpass', 'user') -# db.session.commit() + # Create users + create_user('admin1', 'adminpass', 'admin') + create_user('john_smith', 'password123', 'staff') + create_user('jane_doe', 'password123', 'staff') + create_user('alex_johnson', 'password123', 'staff') + create_user('maria_garcia', 'password123', 'staff') + create_user('robert_chen', 'password123', 'staff') + create_user('emma_davis', 'password123', 'staff') + + # Create schedules + schedule1 = Schedule(name="Week 1 Schedule", created_by=1) + schedule2 = Schedule(name="Week 2 Schedule", created_by=1) + db.session.add(schedule1) + db.session.add(schedule2) + db.session.commit() -# # adding dummy schedule data for testing Jane -# schedule = Schedule ( -# name = "Morning Shift", -# created_by = 1 -# ) -# db.session.add(schedule) -# db.session.commit() + # Create shifts for this week + today = datetime.now() + base_date = today.replace(hour=0, minute=0, second=0, microsecond=0) + + shifts_data = [ + (2, base_date + timedelta(days=0, hours=9), base_date + timedelta(days=0, hours=17)), # Monday + (3, base_date + timedelta(days=0, hours=14), base_date + timedelta(days=0, hours=22)), # Monday + (4, base_date + timedelta(days=1, hours=9), base_date + timedelta(days=1, hours=17)), # Tuesday + (5, base_date + timedelta(days=1, hours=14), base_date + timedelta(days=1, hours=22)), # Tuesday + (6, base_date + timedelta(days=2, hours=9), base_date + timedelta(days=2, hours=17)), # Wednesday + (7, base_date + timedelta(days=2, hours=14), base_date + timedelta(days=2, hours=22)), # Wednesday + (2, base_date + timedelta(days=3, hours=9), base_date + timedelta(days=3, hours=17)), # Thursday + (3, base_date + timedelta(days=3, hours=14), base_date + timedelta(days=3, hours=22)), # Thursday + (4, base_date + timedelta(days=4, hours=9), base_date + timedelta(days=4, hours=17)), # Friday + (5, base_date + timedelta(days=4, hours=14), base_date + timedelta(days=4, hours=22)), # Friday + ] -# # adding dummy shifts for Jane -# shift1 = Shift ( -# schedule_id = schedule.id, -# staff_id = 2, -# start_time = "2024-10-01 08:00:00", -# end_time = "2024-10-01 12:00:00" -# ) -# db.session.add(shift1) \ No newline at end of file + for staff_id, start, end in shifts_data: + shift = Shift( + schedule_id=schedule1.id, + staff_id=staff_id, + start_time=start, + end_time=end + ) + db.session.add(shift) + + db.session.commit() diff --git a/App/controllers/schedule_controller.py b/App/controllers/schedule_controller.py new file mode 100644 index 0000000..db5ecdd --- /dev/null +++ b/App/controllers/schedule_controller.py @@ -0,0 +1,261 @@ +from datetime import datetime, timedelta +from typing import List, Dict, Tuple, Any + +from App.database import db +from App.models import ( + Admin, + Staff, + Shift, + Schedule, + EvenDistributionStrategy, + MinDaysPerWeekStrategy, + BalancedDayNightStrategy, +) + + +class ScheduleController: + + @staticmethod + def clock_in(staff_id: int, shift_id: int) -> Tuple[Dict[str, Any], int]: + shift = Shift.query.get(shift_id) + + if not shift: + return {"error": "Shift not found"}, 404 + + if shift.staff_id != staff_id: + return {"error": "Invalid staff for shift."}, 404 + + shift.clock_in = datetime.utcnow() + db.session.commit() + + return shift.get_json(), 200 + + @staticmethod + def clock_out(staff_id: int, shift_id: int) -> Tuple[Dict[str, Any], int]: + shift = Shift.query.get(shift_id) + + if not shift: + return {"error": "Shift not found"}, 404 + + if shift.staff_id != staff_id: + return {"error": "Invalid staff for shift."}, 404 + + shift.clock_out = datetime.utcnow() + db.session.commit() + + return shift.get_json(), 200 + + @staticmethod + def _get_staff_stats(staff_ids: List[int]) -> Dict[int, Dict[str, float]]: + stats = {} + for staff_id in staff_ids: + staff = Staff.query.get(staff_id) + if staff: + shifts = Shift.query.filter_by(staff_id=staff_id).all() + shifts_assigned = len(shifts) + hours_assigned = sum([shift.calculate_shift_duration_hours() for shift in shifts]) + stats[staff_id] = {"shifts_assigned": shifts_assigned, "hours_assigned": hours_assigned} + return stats + + @staticmethod + def _get_days_worked(staff_ids: List[int]) -> Dict[int, set]: + days_worked = {} + for staff_id in staff_ids: + staff = Staff.query.get(staff_id) + if staff: + shifts = Shift.query.filter_by(staff_id=staff_id).all() + worked_days = {shift.start_time.strftime("%Y-%m-%d") for shift in shifts} + days_worked[staff_id] = worked_days + return days_worked + + @staticmethod + def _get_day_night_stats(staff_ids: List[int], day_shift_hours: Tuple[int, int] = (6, 18)) -> Dict[int, Dict[str, Any]]: + stats = {} + for staff_id in staff_ids: + staff = Staff.query.get(staff_id) + if staff: + shifts = Shift.query.filter_by(staff_id=staff_id).all() + day_count = 0 + night_count = 0 + total_hours = 0 + for shift in shifts: + is_day_shift = BalancedDayNightStrategy.is_day_shift(shift.start_time, day_shift_hours) + if is_day_shift: + day_count += 1 + else: + night_count += 1 + total_hours += shift.calculate_shift_duration_hours() + + stats[staff_id] = { + "day_count": day_count, + "night_count": night_count, + "total_hours": total_hours + } + return stats + + @staticmethod + def auto_populate_schedule( + schedule_id: int, + strategy_type: str, + eligible_staff_ids: List[int], + num_days: int = 7, + shift_start_hour: int = 9, + shift_end_hour: int = 17, + day_shift_hours: Tuple[int, int] = (6, 18), + base_date: datetime = None + ) -> Tuple[Dict[str, Any], int]: + schedule = Schedule.query.get(schedule_id) + if not schedule: + return {"error": "Schedule not found"}, 404 + if not eligible_staff_ids: + return {"error": "No eligible staff IDs provided"}, 400 + if strategy_type not in ('even', 'min_days', 'balanced'): + return {"error": "Invalid strategy type"}, 400 + + if strategy_type == 'even': + strategy = EvenDistributionStrategy(eligible_staff_ids) + elif strategy_type == 'min_days': + strategy = MinDaysPerWeekStrategy(eligible_staff_ids) + else: + strategy = BalancedDayNightStrategy(eligible_staff_ids) + + result_shifts = [] + current_datetime = base_date if base_date else datetime.utcnow() + + for day_offset in range(num_days): + start_time = current_datetime + timedelta(days=day_offset, hours=shift_start_hour) + end_time = current_datetime + timedelta(days=day_offset, hours=shift_end_hour) + + if strategy_type == 'even': + stats = ScheduleController._get_staff_stats(eligible_staff_ids) + best_staff_id = strategy.score_staff(stats) + elif strategy_type == 'min_days': + stats = ScheduleController._get_days_worked(eligible_staff_ids) + target_day = start_time.strftime("%Y-%m-%d") + best_staff_id = strategy.score_staff(stats, target_day) + else: + stats = ScheduleController._get_day_night_stats(eligible_staff_ids, day_shift_hours) + is_day_shift = BalancedDayNightStrategy.is_day_shift(start_time, day_shift_hours) + best_staff_id = strategy.score_staff(stats, is_day_shift) + + shift = Shift(staff_id=best_staff_id, start_time=start_time, end_time=end_time, schedule_id=schedule_id) + db.session.add(shift) + db.session.commit() + result_shifts.append(shift.get_json()) + + return {"shifts": result_shifts, "count": len(result_shifts)}, 201 + + @staticmethod + def schedule_shift_for_staff( + admin_id: int, + staff_id: int, + start_time: datetime, + end_time: datetime, + schedule_id: int = None + ) -> Tuple[Dict[str, Any], int]: + admin = Admin.query.get(admin_id) + if not admin: + return {"error": "Admin not found or unauthorized"}, 404 + staff = Staff.query.get(staff_id) + if not staff: + return {"error": "Staff not found"}, 404 + if start_time >= end_time: + return {"error": "Invalid time range"}, 400 + if schedule_id: + schedule = Schedule.query.get(schedule_id) + if not schedule: + return {"error": "Schedule not found"}, 404 + + shift = Shift(staff_id=staff_id, start_time=start_time, end_time=end_time, schedule_id=schedule_id) + db.session.add(shift) + db.session.commit() + + return shift.get_json(), 201 + + @staticmethod + def view_shift(shift_id: int) -> Tuple[Dict[str, Any], int]: + shift = Shift.query.get(shift_id) + if not shift: + return {"error": "Shift not found"}, 404 + return shift.get_json(), 200 + + @staticmethod + def get_schedule_shifts(schedule_id: int) -> Tuple[Dict[str, Any], int]: + schedule = Schedule.query.get(schedule_id) + if not schedule: + return {"error": "Schedule not found"}, 404 + shifts = Shift.query.filter_by(schedule_id=schedule_id).all() + shifts_json = [shift.get_json() for shift in shifts] + return {"shifts": shifts_json}, 200 + + @staticmethod + def get_staff_weekly_report(staff_id: int, week_start: datetime) -> Tuple[Dict[str, Any], int]: + """ + Get weekly shift report for a staff member including attendance data. + + Args: + staff_id: The staff member's ID + week_start: Start date of the week (datetime) + + Returns: + Dictionary with shift data and attendance info, status code + """ + staff = Staff.query.get(staff_id) + if not staff: + return {"error": "Staff not found"}, 404 + + # Calculate week end (7 days from week start) + week_end = week_start + timedelta(days=7) + + # Get all shifts for this staff in the week + shifts = Shift.query.filter( + Shift.staff_id == staff_id, + Shift.start_time >= week_start, + Shift.start_time < week_end + ).order_by(Shift.start_time).all() + + # Calculate statistics + total_shifts = len(shifts) + total_scheduled_hours = sum(s.calculate_shift_duration_hours() for s in shifts) + + # Calculate attendance based on clock_in/clock_out + attended_shifts = 0 + total_actual_hours = 0 + + for shift in shifts: + if shift.clock_in and shift.clock_out: + attended_shifts += 1 + actual_duration = (shift.clock_out - shift.clock_in).total_seconds() / 3600.0 + total_actual_hours += actual_duration + + attendance_percentage = (attended_shifts / total_shifts * 100) if total_shifts > 0 else 0 + + shift_details = [] + for shift in shifts: + actual_hours = 0 + if shift.clock_in and shift.clock_out: + actual_hours = (shift.clock_out - shift.clock_in).total_seconds() / 3600.0 + + shift_details.append({ + "date": shift.start_time.strftime("%Y-%m-%d"), + "start_time": shift.start_time.strftime("%H:%M"), + "end_time": shift.end_time.strftime("%H:%M"), + "scheduled_hours": shift.calculate_shift_duration_hours(), + "clock_in": shift.clock_in.strftime("%H:%M") if shift.clock_in else "N/A", + "clock_out": shift.clock_out.strftime("%H:%M") if shift.clock_out else "N/A", + "actual_hours": actual_hours, + "attended": "Yes" if shift.clock_in and shift.clock_out else "No" + }) + + return { + "staff_name": staff.username, + "staff_id": staff_id, + "week_start": week_start.strftime("%Y-%m-%d"), + "week_end": week_end.strftime("%Y-%m-%d"), + "total_shifts": total_shifts, + "total_scheduled_hours": round(total_scheduled_hours, 2), + "attended_shifts": attended_shifts, + "total_actual_hours": round(total_actual_hours, 2), + "attendance_percentage": round(attendance_percentage, 1), + "shifts": shift_details + }, 200 diff --git a/App/controllers/shift_controller.py b/App/controllers/shift_controller.py new file mode 100644 index 0000000..e424e78 --- /dev/null +++ b/App/controllers/shift_controller.py @@ -0,0 +1,123 @@ +from datetime import datetime +from typing import Dict, Any, Tuple + +from App.database import db +from App.models import Staff, Shift + + +class ShiftController: + + @staticmethod + def clock_in(staff_id: int, shift_id: int) -> Tuple[Dict[str, Any], int]: + shift = Shift.query.get(shift_id) + + if not shift: + return {"error": "Shift not found"}, 404 + + if shift.staff_id != staff_id: + return {"error": "Invalid staff for shift."}, 404 + + if shift.clock_in is not None: + return {"error": "Already clocked in"}, 400 + + shift.clock_in = datetime.utcnow() + db.session.commit() + + return shift.get_json(), 200 + + @staticmethod + def clock_out(staff_id: int, shift_id: int) -> Tuple[Dict[str, Any], int]: + shift = Shift.query.get(shift_id) + + if not shift: + return {"error": "Shift not found"}, 404 + + if shift.staff_id != staff_id: + return {"error": "Invalid staff for shift."}, 404 + + if shift.clock_in is None: + return {"error": "Not clocked in"}, 400 + + if shift.clock_out is not None: + return {"error": "Already clocked out"}, 400 + + shift.clock_out = datetime.utcnow() + db.session.commit() + + return shift.get_json(), 200 + + @staticmethod + def get_staff_shifts(staff_id: int) -> Tuple[Dict[str, Any], int]: + staff = Staff.query.get(staff_id) + if not staff: + return {"error": "Staff not found"}, 404 + + shifts = Shift.query.filter_by(staff_id=staff_id).order_by(Shift.start_time.desc()).all() + shifts_json = [shift.get_json() for shift in shifts] + + return {"shifts": shifts_json, "count": len(shifts_json)}, 200 + + @staticmethod + def update_shift( + shift_id: int, + start_time: datetime = None, + end_time: datetime = None + ) -> Tuple[Dict[str, Any], int]: + shift = Shift.query.get(shift_id) + if not shift: + return {"error": "Shift not found"}, 404 + + if shift.clock_in or shift.clock_out: + return {"error": "Cannot update shift after clock in/out"}, 400 + + if start_time and end_time and start_time >= end_time: + return {"error": "Start time must be before end time"}, 400 + + if start_time: + shift.start_time = start_time + if end_time: + shift.end_time = end_time + + db.session.add(shift) + db.session.commit() + + return shift.get_json(), 200 + + @staticmethod + def delete_shift(shift_id: int) -> Tuple[Dict[str, Any], int]: + shift = Shift.query.get(shift_id) + if not shift: + return {"error": "Shift not found"}, 404 + + if shift.clock_in or shift.clock_out: + return {"error": "Cannot delete shift after clock in/out"}, 400 + + db.session.delete(shift) + db.session.commit() + + return {"id": shift_id, "deleted": True}, 200 + + @staticmethod + def get_shift_report( + start_date: datetime, + end_date: datetime, + staff_id: int = None + ) -> Tuple[Dict[str, Any], int]: + query = Shift.query.filter(Shift.start_time >= start_date, Shift.end_time <= end_date) + + if staff_id: + query = query.filter_by(staff_id=staff_id) + + shifts = query.all() + + total_shifts = len(shifts) + total_hours = sum([shift.calculate_shift_duration_hours() for shift in shifts]) + avg_hours = total_hours / total_shifts if total_shifts > 0 else 0 + + report = { + "total_shifts": total_shifts, + "total_hours": total_hours, + "average_hours_per_shift": avg_hours + } + + return report, 200 diff --git a/App/controllers/staff.py b/App/controllers/staff.py index 6c21d3a..3ce1cf6 100644 --- a/App/controllers/staff.py +++ b/App/controllers/staff.py @@ -11,29 +11,26 @@ def get_combined_roster(staff_id): def clock_in(staff_id, shift_id): - staff = get_user(staff_id) - if not staff or staff.role != "staff": - raise PermissionError("Only staff can clock in") - shift = db.session.get(Shift, shift_id) - - if not shift or shift.staff_id != staff_id: + if not shift: raise ValueError("Invalid shift for staff") - + if shift.staff_id != staff_id: + raise PermissionError("Only the assigned staff can clock in to this shift.") + if shift.clock_in: + raise ValueError(f"Shift {shift_id} has already been clocked in at {shift.clock_in}.") shift.clock_in = datetime.now() db.session.commit() return shift def clock_out(staff_id, shift_id): - staff = get_user(staff_id) - if not staff or staff.role != "staff": - raise PermissionError("Only staff can clock out") - shift = db.session.get(Shift, shift_id) - if not shift or shift.staff_id != staff_id: + if not shift: raise ValueError("Invalid shift for staff") - + if shift.staff_id != staff_id: + raise PermissionError("Only the assigned staff can clock out of this shift.") + if shift.clock_out: + raise ValueError(f"Shift {shift_id} has already been clocked out at {shift.clock_out}.") shift.clock_out = datetime.now() db.session.commit() return shift diff --git a/App/controllers/user.py b/App/controllers/user.py index 7570136..8823159 100644 --- a/App/controllers/user.py +++ b/App/controllers/user.py @@ -7,7 +7,7 @@ def create_user(username, password, role): role = role.lower().strip() if role not in VALID_ROLES: - print(f"⚠️ Invalid role '{role}'. Must be one of {VALID_ROLES}") + print(f"Invalid role '{role}'. Must be one of {VALID_ROLES}") return None if role == "admin": newuser = Admin(username=username, password=password) diff --git a/App/main.py b/App/main.py index ee392da..24a82f7 100644 --- a/App/main.py +++ b/App/main.py @@ -8,16 +8,9 @@ from App.database import init_db from App.config import load_config - -from App.controllers import ( - setup_jwt, - add_auth_context -) - from App.views import views, setup_admin - def add_views(app): for view in views: app.register_blueprint(view) @@ -26,16 +19,10 @@ def create_app(overrides={}): app = Flask(__name__, static_url_path='/static') load_config(app, overrides) CORS(app) - add_auth_context(app) photos = UploadSet('photos', TEXT + DOCUMENTS + IMAGES) configure_uploads(app, photos) add_views(app) init_db(app) - jwt = setup_jwt(app) setup_admin(app) - @jwt.invalid_token_loader - @jwt.unauthorized_loader - def custom_unauthorized_response(error): - return render_template('401.html', error=error), 401 app.app_context().push() return app \ No newline at end of file diff --git a/App/models/__init__.py b/App/models/__init__.py index 91d63f0..c8f3ec6 100644 --- a/App/models/__init__.py +++ b/App/models/__init__.py @@ -3,4 +3,6 @@ from App.models.staff import Staff from App.models.schedule import Schedule from App.models.shift import Shift +from App.models.shift_swap_request import ShiftSwapRequest +from App.models.scheduling import ShiftSchedulingStrategy, EvenDistributionStrategy, MinDaysPerWeekStrategy, BalancedDayNightStrategy diff --git a/App/models/admin.py b/App/models/admin.py index 479832a..bc040ec 100644 --- a/App/models/admin.py +++ b/App/models/admin.py @@ -1,6 +1,7 @@ from App.database import db from .user import User + class Admin(User): id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True) __mapper_args__ = { @@ -9,3 +10,8 @@ class Admin(User): def __init__(self, username, password): super().__init__(username, password, "admin") + + def get_json(self): + base = super().get_json() + # admin-specific info can be added here + return base diff --git a/App/models/schedule.py b/App/models/schedule.py index 64c0e24..6c0c5ca 100644 --- a/App/models/schedule.py +++ b/App/models/schedule.py @@ -1,11 +1,21 @@ from datetime import datetime from App.database import db + class Schedule(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(50), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) + # who created the schedule (user id) created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + # optional links to the staff the schedule is for and the admin who owns it + staff_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + admin_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + # Track how the schedule was generated: 'manual' or 'auto' + generation_method = db.Column(db.String(20), default='manual', nullable=False) + # If auto-generated, store which strategy was used + strategy_used = db.Column(db.String(50), nullable=True) + shifts = db.relationship("Shift", backref="schedule", lazy=True) def shift_count(self): @@ -15,8 +25,12 @@ def get_json(self): return { "id": self.id, "name": self.name, - "created_at": self.created_at.isoformat(), + "created_at": self.created_at.isoformat() if self.created_at else None, "created_by": self.created_by, + "staff_id": self.staff_id, + "admin_id": self.admin_id, + "generation_method": self.generation_method, + "strategy_used": self.strategy_used, "shift_count": self.shift_count(), "shifts": [shift.get_json() for shift in self.shifts] } diff --git a/App/models/scheduling.py b/App/models/scheduling.py new file mode 100644 index 0000000..39ceb33 --- /dev/null +++ b/App/models/scheduling.py @@ -0,0 +1,177 @@ +from abc import ABC, abstractmethod +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Set +from collections import defaultdict + + +class ShiftSchedulingStrategy(ABC): + """ + Interface for scheduling strategies. + + NOTE: Model layer only calculates/scores. Database queries are handled in the Controller layer. + See: App/controllers/schedule_controller.py for usage. + """ + + @abstractmethod + def score_staff(self, stats: Dict[int, Dict[str, any]]) -> int: + """ + Score eligible staff to determine who should get the shift. + + Args: + stats: Dictionary of staff stats (passed in by controller after querying DB) + + Returns: + staff_id (int) with the best score (lowest value) + """ + raise NotImplementedError() + + +class EvenDistributionStrategy(ShiftSchedulingStrategy): + """Distribute the total number of shifts as evenly as possible across eligible staff. + + Tracks shiftsAssigned and hoursAssigned per staff. For each shift (in date/time order), + determines eligible staff and chooses the one with the lowest shiftsAssigned + (tie-break: lower hoursAssigned, then stable/random choice). + + Checks: + - If feasible, the difference between max and min shiftsAssigned across staff is ≤ 1. + - No hard-constraint violations. + + NOTE: This class is a pure calculator. The Controller queries the database and passes stats. + """ + + def __init__(self, eligible_staff_ids: Optional[List[int]] = None): + """Initialize with optional list of eligible staff IDs.""" + self.eligible_staff_ids = eligible_staff_ids or [] + + @staticmethod + def calculate_shift_duration_hours(start_time: datetime, end_time: datetime) -> float: + """Pure calculation: shift duration in hours.""" + delta = end_time - start_time + return delta.total_seconds() / 3600.0 + + def score_staff(self, stats: Dict[int, Dict[str, float]]) -> int: + """ + Score staff based on shift and hour counts. + + Args: + stats: {staff_id: {"shifts_assigned": int, "hours_assigned": float}, ...} + + Returns: + Best staff_id (lowest shifts_assigned, then lowest hours_assigned) + """ + if not stats: + raise ValueError("No staff stats provided") + + return min( + stats.keys(), + key=lambda sid: (stats[sid]["shifts_assigned"], stats[sid]["hours_assigned"]) + ) + + +class MinDaysPerWeekStrategy(ShiftSchedulingStrategy): + """Minimize the number of distinct days worked per staff (cluster shifts into fewer days). + + Group shifts by day. When assigning a shift, prefer staff who already have a shift on that day. + Penalize assigning a shift that would create a new work day for a staff member. + + Checks: + - Average number of distinct days worked per staff is ≤ the Even strategy baseline. + - No hard-constraint violations. + + NOTE: This class is a pure calculator. The Controller queries the database and passes stats. + """ + + def __init__(self, eligible_staff_ids: Optional[List[int]] = None): + """Initialize with optional list of eligible staff IDs.""" + self.eligible_staff_ids = eligible_staff_ids or [] + + def score_staff(self, stats: Dict[int, Set[str]], target_day: str) -> int: + """ + Score staff based on whether they already work the target day. + + Args: + stats: {staff_id: {"YYYY-MM-DD", "YYYY-MM-DD", ...}, ...} (set of work dates) + target_day: "YYYY-MM-DD" string + + Returns: + Best staff_id (prefers already assigned to target_day) + """ + if not stats: + raise ValueError("No staff stats provided") + + def score(sid): + days_set = stats.get(sid, set()) + if target_day in days_set: + # Already works this day—prefer (score 0) + return (0, len(days_set)) + else: + # Would create new work day—penalize (score 1) + return (1, len(days_set)) + + return min(self.eligible_staff_ids, key=score) + + +class BalancedDayNightStrategy(ShiftSchedulingStrategy): + """Keep the Day/Night shift balance fair for each staff member. + + Track dayCount and nightCount per staff. For a Day shift, prefer staff whose dayCount + is currently low relative to nightCount, and vice versa for Night shifts. + Include load (total hours/shifts) in the score to avoid overloading one person. + + Checks: + - For each staff member, |dayCount − nightCount| is kept small or improved. + - No hard-constraint violations. + + NOTE: This class is a pure calculator. The Controller queries the database and passes stats. + """ + + def __init__(self, eligible_staff_ids: Optional[List[int]] = None, day_shift_hours: Optional[tuple] = None): + """ + Initialize the strategy. + + Args: + eligible_staff_ids: List of staff IDs to consider. + day_shift_hours: Tuple (start_hour, end_hour) defining day shifts (e.g., (6, 18)). + """ + self.eligible_staff_ids = eligible_staff_ids or [] + self.day_shift_hours = day_shift_hours or (6, 18) + + @staticmethod + def is_day_shift(start_time: datetime, day_shift_hours: tuple = (6, 18)) -> bool: + """Pure calculation: determine if shift is day or night based on start hour.""" + hour = start_time.hour + return day_shift_hours[0] <= hour < day_shift_hours[1] + + def score_staff(self, stats: Dict[int, Dict[str, any]], is_day: bool) -> int: + """ + Score staff based on day/night balance. + + Args: + stats: {staff_id: {"day_count": int, "night_count": int, "total_hours": float}, ...} + is_day: True if assigning a day shift, False if night shift + + Returns: + Best staff_id (most balanced day/night distribution) + """ + if not stats: + raise ValueError("No staff stats provided") + + def score(sid): + s = stats.get(sid, {"day_count": 0, "night_count": 0, "total_hours": 0}) + day_count = s["day_count"] + night_count = s["night_count"] + total_hours = s["total_hours"] + + # Balance score: prefer low count of the shift type being assigned + if is_day: + balance_score = day_count - night_count + else: + balance_score = night_count - day_count + + # Load score: penalize high total hours + load_score = total_hours + + return (balance_score, load_score) + + return min(self.eligible_staff_ids, key=score) diff --git a/App/models/shift.py b/App/models/shift.py index 0467dee..64cb939 100644 --- a/App/models/shift.py +++ b/App/models/shift.py @@ -12,6 +12,11 @@ class Shift(db.Model): staff = db.relationship("Staff", backref="shifts", foreign_keys=[staff_id]) + def calculate_shift_duration_hours(self): + """Calculate the duration of this shift in hours.""" + delta = self.end_time - self.start_time + return delta.total_seconds() / 3600.0 + def get_json(self): return { "id": self.id, diff --git a/App/models/shift_swap_request.py b/App/models/shift_swap_request.py new file mode 100644 index 0000000..f548cc4 --- /dev/null +++ b/App/models/shift_swap_request.py @@ -0,0 +1,30 @@ +from datetime import datetime +from App.database import db + + +class ShiftSwapRequest(db.Model): + id = db.Column(db.Integer, primary_key=True) + requesting_staff_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + requested_staff_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + shift_id = db.Column(db.Integer, db.ForeignKey("shift.id"), nullable=False) + reason = db.Column(db.String(255), nullable=True) + status = db.Column(db.String(20), default="pending") # pending, approved, denied + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + requesting_staff = db.relationship("Staff", foreign_keys=[requesting_staff_id], backref="swap_requests_made") + requested_staff = db.relationship("Staff", foreign_keys=[requested_staff_id], backref="swap_requests_received") + shift = db.relationship("Shift", backref="swap_requests") + + def get_json(self): + return { + "id": self.id, + "requesting_staff_id": self.requesting_staff_id, + "requesting_staff_name": self.requesting_staff.username if self.requesting_staff else None, + "requested_staff_id": self.requested_staff_id, + "requested_staff_name": self.requested_staff.username if self.requested_staff else None, + "shift_id": self.shift_id, + "shift_date": self.shift.start_time.isoformat() if self.shift else None, + "reason": self.reason, + "status": self.status, + "created_at": self.created_at.isoformat() if self.created_at else None, + } diff --git a/App/models/staff.py b/App/models/staff.py index bc2592a..427c2a0 100644 --- a/App/models/staff.py +++ b/App/models/staff.py @@ -1,6 +1,7 @@ from App.database import db from .user import User + class Staff(User): id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True) __mapper_args__ = { @@ -9,3 +10,17 @@ class Staff(User): def __init__(self, username, password): super().__init__(username, password, "staff") + + def get_json(self, include_shifts: bool = False): + base = super().get_json() + # include basic shift summary for this staff member only when requested + if include_shifts: + base.update({ + "shifts": [s.get_json() for s in getattr(self, "shifts", [])] + }) + return base + + + def view_roster(self): + """Return this staff member's shifts (as JSON list).""" + return [s.get_json() for s in getattr(self, 'shifts', [])] diff --git a/App/static/style.css b/App/static/style.css index 5f15e0f..2ae5258 100644 --- a/App/static/style.css +++ b/App/static/style.css @@ -1,3 +1,511 @@ -html { +/* ==== Global ==== */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + font-weight: 600; + background: #0f172a; + color: #e2e8f0; +} + +.page-container { + padding: 1.5rem; + max-width: 1200px; + margin: 0 auto 3rem auto; +} + +.page-center { + min-height: calc(100vh - 120px); + display: flex; + align-items: center; + justify-content: center; +} + +/* ==== Top nav ==== */ +.top-nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1.5rem; + background: #1e293b; + border-bottom: 3px solid #3b82f6; + position: sticky; + top: 0; + z-index: 10; +} + +.app-title { + font-family: 'Montserrat', 'Segoe UI', system-ui, sans-serif; + font-weight: 900; + letter-spacing: -0.02em; + color: #60a5fa; + font-size: 1.4rem; + text-transform: uppercase; +} + +.app-title-link { + font-family: 'Montserrat', 'Segoe UI', system-ui, sans-serif; + font-weight: 900; + letter-spacing: -0.02em; + color: #60a5fa; + font-size: 1.4rem; + text-decoration: none; + text-transform: uppercase; + transition: color 0.15s ease; +} + +.app-title-link:hover { + color: #93c5fd; +} + +.nav-identity { + margin-right: 1rem; + font-size: 0.9rem; + opacity: 0.9; + color: #cbd5e1; +} + +/* ==== Cards ==== */ +.card { + background: #1e293b; + border-radius: 8px; + border: 2px solid #334155; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + margin-bottom: 1.5rem; + overflow: hidden; +} + +.card-header { + background: #0f172a; + padding: 1rem 1.5rem; + border-bottom: 2px solid #3b82f6; +} + +.card-title { + margin: 0; + font-size: 1.1rem; + font-weight: 700; + color: #60a5fa; +} + +.card-subtitle { + margin: 0.25rem 0 0 0; + font-size: 0.9rem; + color: #94a3b8; +} + +.card-body { + padding: 1.25rem 1.5rem; +} + +/* Flexbox containers */ +.flex-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 1rem; + align-items: center; +} + +.flex-col { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.flex-between { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} + +.flex-center { + display: flex; + justify-content: center; + align-items: center; +} + +/* Stat boxes */ +.stat-box { + background: #0f172a; + border-radius: 8px; + padding: 1.25rem; + border-left: 4px solid #3b82f6; + flex: 1; + min-width: 180px; +} + +.stat-box.teal { + border-left-color: #14b8a6; +} + +.stat-box.amber { + border-left-color: #f59e0b; +} + +.stat-box.purple { + border-left-color: #8b5cf6; +} + +.stat-label { + margin: 0 0 0.25rem 0; + color: #94a3b8; + font-size: 0.8rem; + text-transform: uppercase; + font-weight: 600; +} + +.stat-value { + margin: 0; + font-size: 2rem; + font-weight: 700; + color: #3b82f6; +} + +.stat-box.teal .stat-value { + color: #14b8a6; +} + +.stat-box.amber .stat-value { + color: #f59e0b; +} + +.stat-box.purple .stat-value { + color: #8b5cf6; +} + +/* Info rows */ +.info-row { + display: flex; + justify-content: space-between; + padding: 0.75rem 0; + border-bottom: 1px solid #334155; +} + +.info-row:last-child { + border-bottom: none; +} + +.info-label { + color: #94a3b8; + font-weight: 600; +} + +.info-value { + color: #e2e8f0; + font-weight: 700; +} + +/* Notice box */ +.notice-box { + background: #0f172a; + border-left: 4px solid #f59e0b; + border-radius: 4px; + padding: 1rem; +} + +.notice-box p { + margin: 0; + font-size: 0.9rem; + color: #fbbf24; +} + +.notice-box.info { + border-left-color: #3b82f6; +} + +.notice-box.info p { + color: #60a5fa; +} + +.notice-box.success { + border-left-color: #14b8a6; +} + +.notice-box.success p { + color: #5eead4; +} + +/* ==== Layout helpers ==== */ +.dashboard-layout { + display: grid; + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); + gap: 1.5rem; +} + +.two-column-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +@media (max-width: 900px) { + .dashboard-layout, + .two-column-layout { + grid-template-columns: 1fr; + } +} + +/* ==== Forms ==== */ +.card-form { + width: 100%; + max-width: 360px; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.35rem; + font-size: 0.9rem; + font-weight: 700; + color: #cbd5e1; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 0.45rem 0.55rem; + border-radius: 4px; + border: 2px solid #475569; + font-size: 0.95rem; + font-weight: 600; + background: #0f172a; + color: #e2e8f0; +} + +.form-group textarea { + resize: vertical; +} + +.form-inline { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* ==== Buttons & links ==== */ +.btn { + display: inline-block; + border: none; + border-radius: 4px; + padding: 0.5rem 0.9rem; + font-size: 0.95rem; + font-weight: 700; + cursor: pointer; + background: #334155; + color: #cbd5e1; + transition: background 0.15s ease, transform 0.05s ease; + text-decoration: none; +} + +.btn.primary { + background: #3b82f6; + color: #fff; +} + +.btn.success { + background: #14b8a6; + color: #fff; +} + +.btn.danger { + background: #8b5cf6; + color: #fff; +} + +.btn.full-width { + width: 100%; + text-align: center; +} + +.btn:hover { + filter: brightness(1.05); + transform: translateY(-1px); +} + +.link { + color: #60a5fa; + text-decoration: none; + font-size: 0.9rem; + font-weight: 700; +} + +.link:hover { + text-decoration: underline; +} + +/* ==== Tables ==== */ +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + font-weight: 600; +} + +.table th, +.table td { + border: 1px solid #334155; + padding: 0.4rem 0.6rem; + text-align: left; +} + +.table thead { + background: #0f172a; + color: #60a5fa; + font-weight: 700; +} + +.table.compact th, +.table.compact td { + padding: 0.3rem 0.5rem; +} + +/* Detail table (two-column) */ +.detail-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + font-weight: 600; +} + +.detail-table th { + text-align: left; + padding: 0.35rem 0.6rem; + width: 35%; + background: #0f172a; + border-bottom: 2px solid #475569; + color: #60a5fa; + font-weight: 700; +} + +.detail-table td { + padding: 0.35rem 0.6rem; + border-bottom: 1px solid #334155; + color: #cbd5e1; +} + +/* ==== Notifications & report summary ==== */ +.notification-list { + list-style: none; + padding-left: 1rem; + margin: 0; + font-size: 0.9rem; + color: #cbd5e1; +} + +.request-actions { + margin-top: 1rem; + display: flex; + gap: 0.5rem; +} + +.report-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.5rem; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.summary-item { + padding: 0.35rem 0.55rem; + background: #0f172a; + border-radius: 4px; + color: #cbd5e1; +} + +.summary-label { + font-weight: 600; + margin-right: 0.3rem; + color: #cbd5e1; +} + +/* ==== Menu card ==== */ +.card-menu .btn { + margin-bottom: 0.5rem; +} + +/* ==== Footer ==== */ +.app-footer { + text-align: center; + font-size: 0.8rem; + color: #64748b; + padding: 0.75rem 0; +} + + +.flash-messages { + list-style: none; + margin: 0 0 0.75rem 0; padding: 0; -} \ No newline at end of file + font-size: 0.85rem; + font-weight: 600; +} + +.flash { + padding: 0.4rem 0.55rem; + border-radius: 4px; + margin-bottom: 0.25rem; +} + +.flash.error { + background: #7f1d1d; + color: #fecaca; + border-left: 4px solid #8b5cf6; +} + +.flash.success { + background: #134e4a; + color: #a7f3d0; + border-left: 4px solid #14b8a6; +} + +.auth-secondary { + margin-top: 0.75rem; + font-size: 0.9rem; + text-align: center; + color: #cbd5e1; +} + +/* ==== Dashboard Stats ==== */ +.dashboard-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 1rem; + margin: 0; +} + +.stat-card { + background: #0f172a; + border: 2px solid #334155; + border-radius: 6px; + padding: 1rem; + text-align: center; +} + +.stat-value { + font-size: 1.8rem; + font-weight: 700; + color: #60a5fa; + line-height: 1; + margin-bottom: 0.35rem; +} + +.stat-label { + font-size: 0.8rem; + color: #94a3b8; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; +} diff --git a/App/templates/401.html b/App/templates/401.html index 91df1a8..ff4a96a 100644 --- a/App/templates/401.html +++ b/App/templates/401.html @@ -1,23 +1,27 @@ {% extends "layout.html" %} -{% block title %}Not Authorized{% endblock %} -{% block page %}Not Auhtorized{% endblock %} - -{{ super() }} +{% block title %}Not Authorized — Scheduloid{% endblock %} {% block content %} -
You do not have permission to access this resource. Please check your credentials and try again.
-{{error}}
+Access Denied
+Hello world
-{% endblock %} \ No newline at end of file +{% block content %} +Overview of your rostering system
+Total Staff
+{{ total_staff }}
+Shifts This Week
+{{ shifts_this_week }}
+Pending Requests
+{{ pending_requests_count }}
+Today's attendance records
+| Staff Member | +Clock In | +Clock Out | +Hours | +Status | +
|---|---|---|---|---|
| {{ record.staff_name }} | +{{ record.clock_in }} | +{{ record.clock_out }} | +{{ record.hours }} | ++ {% if record.status == 'Present' %} + Present + {% elif record.status == 'Clocked In' %} + Clocked In + {% else %} + {{ record.status }} + {% endif %} + | +
No attendance records for today.
+Access the admin dashboard
+Review and manage staff shift swap requests
+Pending
+{{ pending_requests|length }}
+Approved
+{{ approved_requests|length }}
+Denied
+{{ denied_requests|length }}
+{{ pending_requests|length }} request(s) awaiting review
+{{ swap_request.requesting_staff.username }}
+No pending requests.
+No approved requests.
+No denied requests.
+Create a weekly schedule and assign shifts
+Add a new shift for a staff member
+Welcome {{current_user.username}}
- {% endif %} -This is a boileplate flask application which follows the MVC pattern for structuring the project.
-{% endblock %} \ No newline at end of file +Flask MVC Application
++ Welcome, {{ current_user.username }}! +
+ {% endif %} ++ This is a boilerplate Flask application following the MVC pattern for project structure. +
+Submit a request to swap shifts with another staff member
+Select a shift you'd like to request a swap for
+| Date | +Time | +Duration | +
|---|---|---|
| {{ shift.start_time.strftime('%Y-%m-%d (%a)') }} | +{{ shift.start_time.strftime('%H:%M') }} - {{ shift.end_time.strftime('%H:%M') }} | +{{ "%.1f"|format((shift.end_time - shift.start_time).total_seconds() / 3600) }} hrs | +
No upcoming shifts available for swap.
+Fill out the details to request a shift swap
+Your swap request will be submitted to an administrator for review. You will see the admin's response (Approved/Denied) in your "My Swap Requests" page.
+Create a schedule with intelligent shift assignment
+Even Distribution
+Distributes shifts evenly across all staff. Each member gets approximately the same number of shifts.
+Minimize Days Worked
+Clusters shifts to fewer days per week. Staff work complete shifts on fewer days with longer hours.
+Balanced Day/Night
+Balances day shifts (6am-6pm) and night shifts (6pm-6am) fairly among staff members.
+View your assigned shift information
+Generate reports for staff members and review their attendance
+{{ report_data.week_start }} to {{ report_data.week_end }}
+Total Shifts
+{{ report_data.total_shifts }}
+Attended
+{{ report_data.attended_shifts }}
+Attendance Rate
+{{ report_data.attendance_percentage }}%
+Scheduled Hours
+{{ report_data.total_scheduled_hours }}
+Actual Hours
+{{ report_data.total_actual_hours }}
+| Date | +Start | +End | +Scheduled | +Clock In | +Clock Out | +Actual | +Status | +
|---|---|---|---|---|---|---|---|
| {{ shift.date }} | +{{ shift.start_time }} | +{{ shift.end_time }} | +{{ shift.scheduled_hours }} | +{{ shift.clock_in }} | +{{ shift.clock_out }} | +{{ shift.actual_hours }} | ++ {% if shift.attended == 'Yes' %} + Yes + {% else %} + No + {% endif %} + | +
Track your shift start and end times
+Clock In Time
+{% if shift.clock_in %}{{ shift.clock_in[11:19] }}{% else %}—{% endif %}
+Clock Out Time
+{% if shift.clock_out %}{{ shift.clock_out[11:19] }}{% else %}—{% endif %}
+You currently have no shifts assigned for today.
+Contact your administrator to request shift assignments.
+Please ensure you clock in when you arrive and clock out when you leave. Accurate records are important for payroll and scheduling.
+Welcome back! Manage your shifts and schedule
+No shift assigned for today.
+Check back later or view your weekly schedule.
+Shifts Assigned
+{{ shifts_count }}
+Total Hours
+{{ total_hours }}
+Status
+Active
+Sign in to access your dashboard
++ Don't have an account? + Sign up +
+Manage your account settings and view statistics
+Total Shifts
+{{ total_shifts if total_shifts is defined else '0' }}
+Completed
+{{ completed_shifts if completed_shifts is defined else '0' }}
+Update your account password
+Security Tip: Use a strong password with at least 6 characters including letters, numbers, and symbols.
+{{ week_start_display }} to {{ week_end_display }}
+Total Shifts
+{{ total_shifts }}
+Status
+Active
+No shift
+ {% endif %} +View all your shifts and hours worked
+Showing {{ shifts|length if shifts else 0 }} shift(s)
+| Shift Date | +Scheduled Time | +Clock In | +Clock Out | +Hours | +Status | +Actions | +
|---|---|---|---|---|---|---|
| {{ shift.start_time.strftime('%Y-%m-%d (%a)') }} | +{{ shift.start_time.strftime('%H:%M') }} - {{ shift.end_time.strftime('%H:%M') }} | +{% if shift.clock_in %}{{ shift.clock_in.strftime('%H:%M') }}{% else %}—{% endif %} | +{% if shift.clock_out %}{{ shift.clock_out.strftime('%H:%M') }}{% else %}—{% endif %} | +{{ "%.1f"|format((shift.end_time - shift.start_time).total_seconds() / 3600) }} | ++ {% if shift.clock_out %} + Completed + {% elif shift.clock_in %} + In Progress + {% else %} + Scheduled + {% endif %} + | ++ View + | +
No shifts found.
+Check back later for new shift assignments.
+Total Shifts
+{{ shifts|length if shifts else 0 }}
+Hours Worked
+{{ total_hours }}
+Completion Rate
+{{ completion_rate }}%
+Join our roster management system
++ Already have an account? + Back to login +
+View and track your shift swap requests
+Pending
+{{ made_requests|selectattr('status', 'equalto', 'pending')|list|length }}
+Approved
+{{ made_requests|selectattr('status', 'equalto', 'approved')|list|length }}
+Denied
+{{ made_requests|selectattr('status', 'equalto', 'denied')|list|length }}
+Other staff members want to swap shifts with you
+Shift: {{ req.shift.start_time.strftime('%Y-%m-%d %H:%M') if req.shift else 'N/A' }} - {{ req.shift.end_time.strftime('%H:%M') if req.shift else 'N/A' }}
+Reason: {{ req.reason or 'No reason provided' }}
+No pending swap requests from other staff.
+Swap requests you've submitted to admin
+Shift: {{ req.shift.start_time.strftime('%Y-%m-%d %H:%M') if req.shift else 'N/A' }} - {{ req.shift.end_time.strftime('%H:%M') if req.shift else 'N/A' }}
+Reason: {{ req.reason or 'No reason provided' }}
+Submitted: {{ req.created_at.strftime('%Y-%m-%d %H:%M') if req.created_at else 'N/A' }}
+You haven't made any swap requests yet.
+View all registered users in the system
+Total Users
+{{ users|length if users else 0 }}
+Staff Members
+{{ users|selectattr('role', 'equalto', 'staff')|list|length if users else 0 }}
+Administrators
+{{ users|selectattr('role', 'equalto', 'admin')|list|length if users else 0 }}
+Showing {{ users|length if users else 0 }} user(s)
+| User ID | +Username | +Role | +Status | +
|---|---|---|---|
| {{ user.id }} | +{{ user.username }} | ++ {% if user.role == 'admin' %} + Administrator + {% else %} + Staff + {% endif %} + | +Active | +
No users found.
+Users will appear here once they register.
+- This is table is renderd on the Server. Flask gets the data from the database and uses jinja templates to dyanmically render this page when a request is sent to /users. -
+ +Server-rendered user management page
| Id | Username | -
|---|---|
| {{user.id}} | -{{user.username}} | -
{{ users|length if users else 0 }} user(s) registered
+| ID | +Username | +
|---|---|
| {{ user.id }} | +{{ user.username }} | +
No users found. Add a user using the form above.
+Schedule for {{ week_start_display }} to {{ week_end_display }}
+Total Shifts
+{{ total_shifts }}
+Schedule
+{{ selected_schedule.name if selected_schedule else 'None' }}
+{{ day_data.day_name }}
+{{ day_data.short_date }}
+{{ shift.start_time }}-{{ shift.end_time }}
+ +{{ shift.duration }}h
+No shifts
+No schedule data available for this week.
+Shift Scheduling Management System
++ Efficiently manage your team's schedule with our powerful rostering tools. +
+ + + ++ New staff member? + Create an account +
+