/// Rotation configuration for the duty schedule generator. /// /// Stored in `app_settings` with key `rotation_config`. Contains: /// - Ordered list of IT Staff IDs for general rotation /// - Ordered list of non-Islam IT Staff IDs for Friday AM rotation /// - Set of excluded IT Staff IDs /// - Initial AM and PM duty assignments for the next generated schedule /// - Shift type definitions, role → shift type mappings, weekly hour caps /// - Holiday settings (national / custom) class RotationConfig { RotationConfig({ this.rotationOrder = const [], this.fridayAmOrder = const [], this.excludedStaffIds = const [], this.initialAmStaffId, this.initialPmStaffId, List? shiftTypes, Map? roleWeeklyHours, List? holidays, this.syncPhilippinesHolidays = false, this.holidaysYear, }) : shiftTypes = shiftTypes ?? _defaultShiftTypes(), roleWeeklyHours = roleWeeklyHours ?? {}, holidays = holidays ?? []; /// Ordered IDs for standard AM/PM rotation. final List rotationOrder; /// Ordered IDs for Friday AM duty (non-Islam staff only). final List fridayAmOrder; /// Staff IDs excluded from all rotation. final List excludedStaffIds; /// The staff member that should take AM duty on the first day of the next /// generated schedule. `null` means "continue from last week". final String? initialAmStaffId; /// The staff member that should take PM duty on the first day of the next /// generated schedule. `null` means "continue from last week". final String? initialPmStaffId; /// Configured shift types with their time definitions and allowed roles. final List shiftTypes; /// Weekly hour cap per role (e.g. { 'it_staff': 40 }). final Map roleWeeklyHours; /// Holidays to render/flag in the schedule. Includes both synced and local. final List holidays; /// Whether Philippine national holiday sync is enabled. final bool syncPhilippinesHolidays; /// The year used for the last synced holidays. Null means none synced. final int? holidaysYear; factory RotationConfig.fromJson(Map json) { return RotationConfig( rotationOrder: _toStringList(json['rotation_order']), fridayAmOrder: _toStringList(json['friday_am_order']), excludedStaffIds: _toStringList(json['excluded_staff_ids']), initialAmStaffId: json['initial_am_staff_id'] as String?, initialPmStaffId: json['initial_pm_staff_id'] as String?, shiftTypes: _toShiftTypes(json['shift_types']), roleWeeklyHours: _toStringIntMap(json['role_weekly_hours']), holidays: _toHolidays(json['holidays']), syncPhilippinesHolidays: json['sync_philippines_holidays'] as bool? ?? false, holidaysYear: json['holidays_year'] as int?, ); } Map toJson() => { 'rotation_order': rotationOrder, 'friday_am_order': fridayAmOrder, 'excluded_staff_ids': excludedStaffIds, 'initial_am_staff_id': initialAmStaffId, 'initial_pm_staff_id': initialPmStaffId, 'shift_types': shiftTypes.map((e) => e.toJson()).toList(), 'role_weekly_hours': roleWeeklyHours, 'holidays': holidays.map((e) => e.toJson()).toList(), 'sync_philippines_holidays': syncPhilippinesHolidays, 'holidays_year': holidaysYear, }; RotationConfig copyWith({ List? rotationOrder, List? fridayAmOrder, List? excludedStaffIds, String? initialAmStaffId, String? initialPmStaffId, bool clearInitialAm = false, bool clearInitialPm = false, List? shiftTypes, Map? roleWeeklyHours, List? holidays, bool? syncPhilippinesHolidays, int? holidaysYear, }) { return RotationConfig( rotationOrder: rotationOrder ?? this.rotationOrder, fridayAmOrder: fridayAmOrder ?? this.fridayAmOrder, excludedStaffIds: excludedStaffIds ?? this.excludedStaffIds, initialAmStaffId: clearInitialAm ? null : (initialAmStaffId ?? this.initialAmStaffId), initialPmStaffId: clearInitialPm ? null : (initialPmStaffId ?? this.initialPmStaffId), shiftTypes: shiftTypes ?? this.shiftTypes, roleWeeklyHours: roleWeeklyHours ?? this.roleWeeklyHours, holidays: holidays ?? this.holidays, syncPhilippinesHolidays: syncPhilippinesHolidays ?? this.syncPhilippinesHolidays, holidaysYear: holidaysYear ?? this.holidaysYear, ); } static List _defaultShiftTypes() { return const [ ShiftTypeConfig( id: 'am', label: 'AM Duty', startHour: 7, startMinute: 0, durationMinutes: 8 * 60, allowedRoles: ['it_staff'], ), ShiftTypeConfig( id: 'pm', label: 'PM Duty', startHour: 15, startMinute: 0, durationMinutes: 8 * 60, allowedRoles: ['it_staff'], ), ShiftTypeConfig( id: 'on_call', label: 'On Call', startHour: 23, startMinute: 0, durationMinutes: 8 * 60, allowedRoles: ['it_staff'], ), ShiftTypeConfig( id: 'on_call_saturday', label: 'On Call (Saturday)', startHour: 17, startMinute: 0, durationMinutes: 15 * 60, allowedRoles: ['it_staff'], ), ShiftTypeConfig( id: 'on_call_sunday', label: 'On Call (Sunday)', startHour: 17, startMinute: 0, durationMinutes: 14 * 60, allowedRoles: ['it_staff'], ), ShiftTypeConfig( id: 'normal', label: 'Normal', startHour: 8, startMinute: 0, durationMinutes: 9 * 60, allowedRoles: ['admin', 'dispatcher', 'programmer', 'it_staff'], ), ShiftTypeConfig( id: 'normal_ramadan_islam', label: 'Normal (Ramadan - Islam)', startHour: 8, startMinute: 0, durationMinutes: 8 * 60, allowedRoles: ['admin', 'dispatcher', 'programmer', 'it_staff'], ), ShiftTypeConfig( id: 'normal_ramadan_other', label: 'Normal (Ramadan - Other)', startHour: 8, startMinute: 0, durationMinutes: 9 * 60, allowedRoles: ['admin', 'dispatcher', 'programmer', 'it_staff'], ), ]; } static List _toStringList(dynamic value) { if (value is List) return value.map((e) => e.toString()).toList(); return []; } static List _toShiftTypes(dynamic value) { if (value is List) { return value .whereType>() .map(ShiftTypeConfig.fromJson) .toList(); } return _defaultShiftTypes(); } static Map _toStringIntMap(dynamic value) { if (value is Map) { return value.map((key, val) { final intVal = val is int ? val : int.tryParse(val?.toString() ?? '') ?? 0; return MapEntry(key.toString(), intVal); }); } return {}; } static List _toHolidays(dynamic value) { if (value is List) { return value .whereType>() .map(Holiday.fromJson) .toList(); } return []; } } /// A configurable shift type used by the schedule generator. class ShiftTypeConfig { const ShiftTypeConfig({ required this.id, required this.label, required this.startHour, required this.startMinute, required this.durationMinutes, this.noonBreakMinutes = 60, this.allowedRoles = const [], }); /// Unique shift type ID (e.g. "am", "pm", "on_call"). final String id; /// Human-readable label (e.g. "AM Duty"). final String label; /// Hour of day (0-23) when this shift begins. final int startHour; /// Minute (0-59) when this shift begins. final int startMinute; /// Duration in minutes. final int durationMinutes; /// Minutes reserved for a noon/meal break during the shift. final int noonBreakMinutes; /// Roles that are allowed to be assigned this shift type. final List allowedRoles; Duration get duration => Duration(minutes: durationMinutes); Map toJson() => { 'id': id, 'label': label, 'start_hour': startHour, 'start_minute': startMinute, 'duration_minutes': durationMinutes, 'noon_break_minutes': noonBreakMinutes, 'allowed_roles': allowedRoles, }; factory ShiftTypeConfig.fromJson(Map json) { return ShiftTypeConfig( id: json['id'] as String, label: json['label'] as String? ?? json['id'] as String, startHour: json['start_hour'] is int ? json['start_hour'] as int : int.tryParse(json['start_hour']?.toString() ?? '') ?? 0, startMinute: json['start_minute'] is int ? json['start_minute'] as int : int.tryParse(json['start_minute']?.toString() ?? '') ?? 0, durationMinutes: json['duration_minutes'] is int ? json['duration_minutes'] as int : int.tryParse(json['duration_minutes']?.toString() ?? '') ?? 0, noonBreakMinutes: json['noon_break_minutes'] is int ? json['noon_break_minutes'] as int : int.tryParse(json['noon_break_minutes']?.toString() ?? '') ?? 60, allowedRoles: json['allowed_roles'] is List ? (json['allowed_roles'] as List).map((e) => e.toString()).toList() : [], ); } } /// A holiday used to flag schedule days. class Holiday { Holiday({required this.date, required this.name, this.source}); /// Date of the holiday (only date portion is used). final DateTime date; /// Holiday name (e.g. "Independence Day"). final String name; /// Optional source (e.g. "nager") to distinguish synced vs custom. final String? source; Map toJson() => { 'date': date.toIso8601String(), 'name': name, 'source': source, }; factory Holiday.fromJson(Map json) { final dateRaw = json['date']; DateTime date; if (dateRaw is String) { date = DateTime.parse(dateRaw); } else if (dateRaw is DateTime) { date = dateRaw; } else { date = DateTime.now(); } return Holiday( date: DateTime(date.year, date.month, date.day), name: json['name'] as String? ?? 'Holiday', source: json['source'] as String?, ); } }