tasq/lib/models/rotation_config.dart

332 lines
10 KiB
Dart

/// 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<ShiftTypeConfig>? shiftTypes,
Map<String, int>? roleWeeklyHours,
List<Holiday>? holidays,
this.syncPhilippinesHolidays = false,
this.holidaysYear,
}) : shiftTypes = shiftTypes ?? _defaultShiftTypes(),
roleWeeklyHours = roleWeeklyHours ?? {},
holidays = holidays ?? [];
/// Ordered IDs for standard AM/PM rotation.
final List<String> rotationOrder;
/// Ordered IDs for Friday AM duty (non-Islam staff only).
final List<String> fridayAmOrder;
/// Staff IDs excluded from all rotation.
final List<String> 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<ShiftTypeConfig> shiftTypes;
/// Weekly hour cap per role (e.g. { 'it_staff': 40 }).
final Map<String, int> roleWeeklyHours;
/// Holidays to render/flag in the schedule. Includes both synced and local.
final List<Holiday> 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<String, dynamic> 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<String, dynamic> 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<String>? rotationOrder,
List<String>? fridayAmOrder,
List<String>? excludedStaffIds,
String? initialAmStaffId,
String? initialPmStaffId,
bool clearInitialAm = false,
bool clearInitialPm = false,
List<ShiftTypeConfig>? shiftTypes,
Map<String, int>? roleWeeklyHours,
List<Holiday>? 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<ShiftTypeConfig> _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<String> _toStringList(dynamic value) {
if (value is List) return value.map((e) => e.toString()).toList();
return [];
}
static List<ShiftTypeConfig> _toShiftTypes(dynamic value) {
if (value is List) {
return value
.whereType<Map<String, dynamic>>()
.map(ShiftTypeConfig.fromJson)
.toList();
}
return _defaultShiftTypes();
}
static Map<String, int> _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<Holiday> _toHolidays(dynamic value) {
if (value is List) {
return value
.whereType<Map<String, dynamic>>()
.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<String> allowedRoles;
Duration get duration => Duration(minutes: durationMinutes);
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() => {
'date': date.toIso8601String(),
'name': name,
'source': source,
};
factory Holiday.fromJson(Map<String, dynamic> 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?,
);
}
}