diff --git a/lib/models/rotation_config.dart b/lib/models/rotation_config.dart index ecfe27fa..9e8c74ab 100644 --- a/lib/models/rotation_config.dart +++ b/lib/models/rotation_config.dart @@ -5,16 +5,23 @@ /// - 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({ - List? rotationOrder, - List? fridayAmOrder, - List? excludedStaffIds, + this.rotationOrder = const [], + this.fridayAmOrder = const [], + this.excludedStaffIds = const [], this.initialAmStaffId, this.initialPmStaffId, - }) : rotationOrder = rotationOrder ?? [], - fridayAmOrder = fridayAmOrder ?? [], - excludedStaffIds = excludedStaffIds ?? []; + 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; @@ -33,6 +40,21 @@ class RotationConfig { /// 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']), @@ -40,6 +62,12 @@ class RotationConfig { 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?, ); } @@ -49,6 +77,11 @@ class RotationConfig { '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({ @@ -59,6 +92,11 @@ class RotationConfig { String? initialPmStaffId, bool clearInitialAm = false, bool clearInitialPm = false, + List? shiftTypes, + Map? roleWeeklyHours, + List? holidays, + bool? syncPhilippinesHolidays, + int? holidaysYear, }) { return RotationConfig( rotationOrder: rotationOrder ?? this.rotationOrder, @@ -70,11 +108,224 @@ class RotationConfig { 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?, + ); + } } diff --git a/lib/providers/rotation_config_provider.dart b/lib/providers/rotation_config_provider.dart index 7b12142c..ff4f9b34 100644 --- a/lib/providers/rotation_config_provider.dart +++ b/lib/providers/rotation_config_provider.dart @@ -48,4 +48,34 @@ class RotationConfigController { }); _ref.invalidate(rotationConfigProvider); } + + Future updateShiftTypes(List shiftTypes) async { + final config = await _ref.read(rotationConfigProvider.future); + await save(config.copyWith(shiftTypes: shiftTypes)); + } + + Future updateRoleWeeklyHours(Map roleWeeklyHours) async { + final config = await _ref.read(rotationConfigProvider.future); + await save(config.copyWith(roleWeeklyHours: roleWeeklyHours)); + } + + Future updateHolidays(List holidays) async { + final config = await _ref.read(rotationConfigProvider.future); + await save(config.copyWith(holidays: holidays)); + } + + Future setHolidaySync({ + required bool enabled, + required int year, + required List holidays, + }) async { + final config = await _ref.read(rotationConfigProvider.future); + await save( + config.copyWith( + syncPhilippinesHolidays: enabled, + holidaysYear: year, + holidays: holidays, + ), + ); + } } diff --git a/lib/screens/workforce/rotation_settings_dialog.dart b/lib/screens/workforce/rotation_settings_dialog.dart index 5d108718..8966fe10 100644 --- a/lib/screens/workforce/rotation_settings_dialog.dart +++ b/lib/screens/workforce/rotation_settings_dialog.dart @@ -7,6 +7,8 @@ import '../../providers/profile_provider.dart'; import '../../providers/rotation_config_provider.dart'; import '../../theme/app_surfaces.dart'; import '../../theme/m3_motion.dart'; +import '../../services/holidays_service.dart'; +import '../../utils/app_time.dart'; import '../../utils/snackbar.dart'; /// Opens rotation settings as a dialog (desktop/tablet) or bottom sheet @@ -53,7 +55,7 @@ class _RotationSettingsDialogState @override void initState() { super.initState(); - _tabCtrl = TabController(length: 4, vsync: this); + _tabCtrl = TabController(length: 6, vsync: this); } @override @@ -83,6 +85,8 @@ class _RotationSettingsDialogState Tab(text: 'Friday AM'), Tab(text: 'Excluded'), Tab(text: 'Initial Duty'), + Tab(text: 'Shift Types'), + Tab(text: 'Holidays'), ], ), Flexible( @@ -93,6 +97,8 @@ class _RotationSettingsDialogState _FridayAmTab(), _ExcludedTab(), _InitialDutyTab(), + _ShiftTypesTab(), + _HolidaySettingsTab(), ], ), ), @@ -148,7 +154,7 @@ class _RotationSettingsSheetState extends ConsumerState<_RotationSettingsSheet> @override void initState() { super.initState(); - _tabCtrl = TabController(length: 4, vsync: this); + _tabCtrl = TabController(length: 6, vsync: this); } @override @@ -192,6 +198,8 @@ class _RotationSettingsSheetState extends ConsumerState<_RotationSettingsSheet> Tab(text: 'Friday AM'), Tab(text: 'Excluded'), Tab(text: 'Initial Duty'), + Tab(text: 'Shift Types'), + Tab(text: 'Holidays'), ], ), Expanded( @@ -202,6 +210,8 @@ class _RotationSettingsSheetState extends ConsumerState<_RotationSettingsSheet> _FridayAmTab(), _ExcludedTab(), _InitialDutyTab(), + _ShiftTypesTab(), + _HolidaySettingsTab(), ], ), ), @@ -795,20 +805,643 @@ class _SaveButton extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: saving ? null : onSave, - icon: saving - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save_outlined), - label: const Text('Save'), - ), + child: FilledButton.icon( + onPressed: saving ? null : onSave, + icon: saving + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Save'), ), ); } } + +// ═══════════════════════════════════════════════════════════════ +// Tab 5: Shift Types (configurable shift type definitions & role mapping) +// ═══════════════════════════════════════════════════════════════ +class _ShiftTypesTab extends ConsumerStatefulWidget { + const _ShiftTypesTab(); + + @override + ConsumerState<_ShiftTypesTab> createState() => _ShiftTypesTabState(); +} + +class _ShiftTypesTabState extends ConsumerState<_ShiftTypesTab> { + List? _shiftTypes; + Map _weeklyHours = {}; + bool _saving = false; + + static const _knownRoles = [ + 'it_staff', + 'admin', + 'dispatcher', + 'programmer', + 'standard', + ]; + + void _ensureLoaded() { + if (_shiftTypes != null) return; + final config = ref.read(rotationConfigProvider).valueOrNull; + + // The default rotation config uses const shift type definitions. + // Make a mutable copy so the UI can edit allowedRoles safely. + final baseShiftTypes = config?.shiftTypes ?? RotationConfig().shiftTypes; + _shiftTypes = baseShiftTypes + .map( + (s) => ShiftTypeConfig( + id: s.id, + label: s.label, + startHour: s.startHour, + startMinute: s.startMinute, + durationMinutes: s.durationMinutes, + allowedRoles: List.from(s.allowedRoles), + ), + ) + .toList(); + + _weeklyHours = config?.roleWeeklyHours ?? {}; + } + + @override + Widget build(BuildContext context) { + final configAsync = ref.watch(rotationConfigProvider); + if (configAsync.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + _ensureLoaded(); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Weekly hours per role (hard cap). Leave blank for no limit.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final role in _knownRoles) + SizedBox( + width: 140, + child: TextFormField( + initialValue: _weeklyHours[role]?.toString() ?? '', + decoration: InputDecoration( + labelText: role, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + onChanged: (value) { + final hours = int.tryParse(value); + setState(() { + if (hours != null) { + _weeklyHours[role] = hours; + } else { + _weeklyHours.remove(role); + } + }); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _shiftTypes!.length, + separatorBuilder: (context, index) => const Divider(), + itemBuilder: (context, index) { + final shift = _shiftTypes![index]; + final startTime = TimeOfDay( + hour: shift.startHour, + minute: shift.startMinute, + ); + return ExpansionTile( + key: ValueKey(shift.id), + title: Text(shift.label), + subtitle: Text('ID: ${shift.id}'), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + 'Start: ${startTime.format(context)}', + ), + ), + Expanded( + child: Text( + 'Duration: ${shift.durationMinutes ~/ 60}h ${shift.durationMinutes % 60}m', + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Allowed roles', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final role in _knownRoles) + FilterChip( + label: Text(role), + selected: shift.allowedRoles.contains(role), + onSelected: (selected) { + setState(() { + if (selected) { + shift.allowedRoles.add(role); + } else { + shift.allowedRoles.remove(role); + } + }); + }, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + onPressed: () => _editShiftType(context, index), + icon: const Icon(Icons.edit, size: 18), + label: const Text('Edit'), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: () => _removeShiftType(index), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Delete'), + ), + ], + ), + const SizedBox(height: 12), + ], + ), + ), + ], + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: _addShiftType, + icon: const Icon(Icons.add), + label: const Text('Add shift type'), + ), + const Spacer(), + _SaveButton(saving: _saving, onSave: () => _save(context)), + ], + ), + ), + ], + ); + } + + Future _addShiftType() async { + final newShift = ShiftTypeConfig( + id: 'new_shift_${DateTime.now().millisecondsSinceEpoch}', + label: 'New Shift', + startHour: 8, + startMinute: 0, + durationMinutes: 480, + noonBreakMinutes: 60, + allowedRoles: [], + ); + setState(() { + _shiftTypes = [...?_shiftTypes, newShift]; + }); + await _editShiftType(context, _shiftTypes!.length - 1); + } + + Future _removeShiftType(int index) async { + setState(() { + _shiftTypes = [...?_shiftTypes]..removeAt(index); + }); + } + + Future _editShiftType(BuildContext context, int index) async { + final shift = _shiftTypes![index]; + var label = shift.label; + var start = TimeOfDay(hour: shift.startHour, minute: shift.startMinute); + var durationMinutes = shift.durationMinutes; + var noonBreakMinutes = shift.noonBreakMinutes; + + final confirmed = await m3ShowDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + shape: AppSurfaces.of(context).dialogShape, + title: const Text('Edit Shift Type'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + initialValue: label, + decoration: const InputDecoration(labelText: 'Label'), + onChanged: (v) => setState(() => label = v), + ), + const SizedBox(height: 12), + InkWell( + onTap: () async { + final picked = await showTimePicker( + context: context, + initialTime: start, + ); + if (picked != null) setState(() => start = picked); + }, + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'Start time', + ), + child: Text(start.format(context)), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + initialValue: (durationMinutes ~/ 60).toString(), + decoration: const InputDecoration( + labelText: 'Hours (total)', + ), + keyboardType: TextInputType.number, + onChanged: (v) { + final h = int.tryParse(v) ?? 0; + setState(() { + durationMinutes = + h * 60 + (durationMinutes % 60); + }); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + initialValue: (durationMinutes % 60).toString(), + decoration: const InputDecoration( + labelText: 'Minutes', + ), + keyboardType: TextInputType.number, + onChanged: (v) { + final m = int.tryParse(v) ?? 0; + setState(() { + durationMinutes = + (durationMinutes ~/ 60) * 60 + m; + }); + }, + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + initialValue: noonBreakMinutes.toString(), + decoration: const InputDecoration( + labelText: 'Noon break (minutes)', + ), + keyboardType: TextInputType.number, + onChanged: (v) { + final m = int.tryParse(v) ?? 0; + setState(() { + noonBreakMinutes = m; + }); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + + if (confirmed != true) return; + + setState(() { + _shiftTypes = [...?_shiftTypes]; + _shiftTypes![index] = ShiftTypeConfig( + id: shift.id, + label: label, + startHour: start.hour, + startMinute: start.minute, + durationMinutes: durationMinutes, + noonBreakMinutes: noonBreakMinutes, + allowedRoles: shift.allowedRoles, + ); + }); + } + + Future _save(BuildContext context) async { + final ctx = context; + setState(() => _saving = true); + try { + await ref + .read(rotationConfigControllerProvider) + .updateShiftTypes(_shiftTypes!); + await ref + .read(rotationConfigControllerProvider) + .updateRoleWeeklyHours(_weeklyHours); + if (mounted) { + // ignore: use_build_context_synchronously + showSuccessSnackBar(ctx, 'Shift types saved.'); + } + } catch (e) { + if (mounted) { + // ignore: use_build_context_synchronously + showErrorSnackBar(ctx, 'Save failed: $e'); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Tab 6: Holiday settings (sync + custom holidays) +// ═══════════════════════════════════════════════════════════════ +class _HolidaySettingsTab extends ConsumerStatefulWidget { + const _HolidaySettingsTab(); + + @override + ConsumerState<_HolidaySettingsTab> createState() => + _HolidaySettingsTabState(); +} + +class _HolidaySettingsTabState extends ConsumerState<_HolidaySettingsTab> { + bool _syncEnabled = false; + int _year = DateTime.now().year; + bool _saving = false; + List _holidays = []; + bool _loaded = false; + + void _ensureLoaded() { + if (_loaded) return; + final config = ref.read(rotationConfigProvider).valueOrNull; + _holidays = config?.holidays ?? []; + _syncEnabled = config?.syncPhilippinesHolidays ?? false; + _year = config?.holidaysYear ?? DateTime.now().year; + _loaded = true; + } + + @override + Widget build(BuildContext context) { + final configAsync = ref.watch(rotationConfigProvider); + if (configAsync.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + _ensureLoaded(); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Holidays are used to flag shifts that occur on special dates. ' + 'You can sync Philippine national holidays or add custom dates.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: SwitchListTile( + value: _syncEnabled, + title: const Text('Sync Philippine holidays'), + onChanged: (value) => setState(() { + _syncEnabled = value; + }), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 120, + child: TextFormField( + initialValue: _year.toString(), + decoration: const InputDecoration(labelText: 'Year'), + keyboardType: TextInputType.number, + onChanged: (value) { + final parsed = int.tryParse(value); + if (parsed != null) { + setState(() => _year = parsed); + } + }, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + FilledButton( + onPressed: _syncEnabled ? _syncHolidays : null, + child: const Text('Sync now'), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: _addCustomHoliday, + child: const Text('Add custom holiday'), + ), + ], + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _holidays.length, + separatorBuilder: (context, index) => const Divider(), + itemBuilder: (context, index) { + final holiday = _holidays[index]; + return ListTile( + title: Text(holiday.name), + subtitle: Text(AppTime.formatDate(holiday.date)), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _removeHoliday(index), + ), + ); + }, + ), + ), + _SaveButton(saving: _saving, onSave: () => _save(context)), + ], + ); + } + + Future _syncHolidays() async { + final ctx = context; + setState(() => _saving = true); + try { + final holidays = await HolidaysService.fetchPhilippinesHolidays(_year); + setState(() { + _holidays = holidays; + }); + await ref + .read(rotationConfigControllerProvider) + .setHolidaySync( + enabled: _syncEnabled, + year: _year, + holidays: _holidays, + ); + if (mounted) { + // ignore: use_build_context_synchronously + showSuccessSnackBar(ctx, 'Holidays synced for $_year.'); + } + } catch (e) { + if (mounted) { + // ignore: use_build_context_synchronously + showErrorSnackBar(ctx, 'Sync failed: $e'); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _addCustomHoliday() async { + DateTime selectedDate = DateTime.now(); + String name = ''; + + final confirmed = await m3ShowDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + shape: AppSurfaces.of(context).dialogShape, + title: const Text('Add custom holiday'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: selectedDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() => selectedDate = picked); + } + }, + child: InputDecorator( + decoration: const InputDecoration(labelText: 'Date'), + child: Text(AppTime.formatDate(selectedDate)), + ), + ), + const SizedBox(height: 12), + TextFormField( + decoration: const InputDecoration(labelText: 'Name'), + onChanged: (value) => setState(() => name = value), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Add'), + ), + ], + ); + }, + ); + }, + ); + + if (confirmed != true || name.isEmpty) return; + + setState(() { + _holidays = [ + ..._holidays, + Holiday(date: selectedDate, name: name, source: 'custom'), + ]; + }); + } + + void _removeHoliday(int index) { + setState(() { + _holidays = [..._holidays]..removeAt(index); + }); + } + + Future _save(BuildContext context) async { + final ctx = context; + setState(() => _saving = true); + try { + await ref + .read(rotationConfigControllerProvider) + .setHolidaySync( + enabled: _syncEnabled, + year: _year, + holidays: _holidays, + ); + if (mounted) { + // ignore: use_build_context_synchronously + showSuccessSnackBar(ctx, 'Holidays saved.'); + } + } catch (e) { + if (mounted) { + // ignore: use_build_context_synchronously + showErrorSnackBar(ctx, 'Save failed: $e'); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } +} diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index 28840baf..05ef472a 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -97,6 +97,7 @@ class _SchedulePanel extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final schedulesAsync = ref.watch(dutySchedulesProvider); final profilesAsync = ref.watch(profilesProvider); + final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; final currentUserId = ref.watch(currentUserIdProvider); final showPast = ref.watch(showPastSchedulesProvider); @@ -183,6 +184,7 @@ class _SchedulePanel extends ConsumerWidget { profileById, schedule, isAdmin, + rotationConfig, ), relieverLabels: _relieverLabelsFromIds( schedule.relieverIds, @@ -211,12 +213,13 @@ class _SchedulePanel extends ConsumerWidget { Map profileById, DutySchedule schedule, bool isAdmin, + RotationConfig? rotationConfig, ) { final profile = profileById[schedule.userId]; final name = profile?.fullName.isNotEmpty == true ? profile!.fullName : schedule.userId; - return '${_shiftLabel(schedule.shiftType)} · $name'; + return '${_shiftLabel(schedule.shiftType, rotationConfig)} · $name'; } static String _dayOfWeek(DateTime day) { @@ -224,7 +227,21 @@ class _SchedulePanel extends ConsumerWidget { return days[day.weekday - 1]; } - String _shiftLabel(String value) { + String _shiftLabel(String value, RotationConfig? config) { + final configured = config?.shiftTypes.firstWhere( + (s) => s.id == value, + orElse: () => ShiftTypeConfig( + id: value, + label: value, + startHour: 0, + startMinute: 0, + durationMinutes: 0, + ), + ); + if (configured != null && configured.label.isNotEmpty) { + return configured.label; + } + switch (value) { case 'am': return 'AM Duty'; @@ -285,6 +302,42 @@ class _ScheduleTile extends ConsumerWidget { ); final canRequestSwap = isMine && schedule.status != 'absent' && !isPast; + final profiles = ref.watch(profilesProvider).valueOrNull ?? []; + Profile? profile; + try { + profile = profiles.firstWhere((p) => p.id == schedule.userId); + } catch (_) { + profile = null; + } + final role = profile?.role; + final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; + + ShiftTypeConfig? shiftTypeConfig; + if (rotationConfig != null) { + try { + shiftTypeConfig = rotationConfig.shiftTypes.firstWhere( + (s) => s.id == schedule.shiftType, + ); + } catch (_) { + shiftTypeConfig = null; + } + } + + final isInvalidShiftForRole = + role != null && + shiftTypeConfig != null && + shiftTypeConfig.allowedRoles.isNotEmpty && + !shiftTypeConfig.allowedRoles.contains(role); + + final isHoliday = + rotationConfig?.holidays.any( + (h) => + h.date.year == schedule.startTime.year && + h.date.month == schedule.startTime.month && + h.date.day == schedule.startTime.day, + ) == + true; + return Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), @@ -314,6 +367,48 @@ class _ScheduleTile extends ConsumerWidget { color: _statusColor(context, schedule.status), ), ), + if (isHoliday) ...[ + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.calendar_month, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + 'Holiday', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.primary, + ), + ), + ], + ), + ], + if (isInvalidShiftForRole) ...[ + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.warning_amber, + size: 16, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 4), + Text( + 'Shift not allowed for role', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + ], ], ), ), @@ -389,11 +484,46 @@ class _ScheduleTile extends ConsumerWidget { var startTime = TimeOfDay.fromDateTime(schedule.startTime); var endTime = TimeOfDay.fromDateTime(schedule.endTime); + final rotationConfig = ref.read(rotationConfigProvider).valueOrNull; + final shiftTypeConfigs = + rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes; + final confirmed = await m3ShowDialog( context: context, builder: (dialogContext) { return StatefulBuilder( builder: (context, setState) { + final selectedRole = () { + try { + return staff.firstWhere((p) => p.id == selectedUserId).role; + } catch (_) { + return null; + } + }(); + + final allowed = shiftTypeConfigs + .where( + (s) => + selectedRole == null || + s.allowedRoles.isEmpty || + s.allowedRoles.contains(selectedRole), + ) + .toList(); + // Ensure the currently selected shift is always available. + if (!allowed.any((s) => s.id == selectedShift)) { + final existing = shiftTypeConfigs.firstWhere( + (s) => s.id == selectedShift, + orElse: () => ShiftTypeConfig( + id: selectedShift, + label: selectedShift, + startHour: 0, + startMinute: 0, + durationMinutes: 0, + ), + ); + allowed.add(existing); + } + return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Edit Schedule'), @@ -420,17 +550,12 @@ class _ScheduleTile extends ConsumerWidget { const SizedBox(height: 12), DropdownButtonFormField( initialValue: selectedShift, - items: const [ - DropdownMenuItem(value: 'am', child: Text('AM Duty')), - DropdownMenuItem(value: 'pm', child: Text('PM Duty')), - DropdownMenuItem( - value: 'on_call', - child: Text('On Call'), - ), - DropdownMenuItem( - value: 'normal', - child: Text('Normal'), - ), + items: [ + for (final type in allowed) + DropdownMenuItem( + value: type.id, + child: Text(type.label), + ), ], onChanged: (v) { if (v != null) setState(() => selectedShift = v); @@ -784,6 +909,7 @@ class _DraftSchedule { required this.shiftType, required this.startTime, required this.endTime, + this.isHoliday = false, List? relieverIds, }) : relieverIds = relieverIds ?? []; @@ -793,6 +919,7 @@ class _DraftSchedule { DateTime startTime; DateTime endTime; List relieverIds; + bool isHoliday; } class _RotationEntry { @@ -1027,7 +1154,9 @@ class _ScheduleGeneratorPanelState final rotationConfig = ref.read(rotationConfigProvider).valueOrNull; final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; - final templates = _buildTemplates(schedules); + final shiftTypes = + rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes; + final templates = _buildTemplates(schedules, shiftTypes); final generated = _generateDrafts( start, end, @@ -1037,7 +1166,7 @@ class _ScheduleGeneratorPanelState rotationConfig: rotationConfig, ); generated.sort((a, b) => a.startTime.compareTo(b.startTime)); - final warnings = _buildWarnings(start, end, generated); + final warnings = _buildWarnings(start, end, generated, rotationConfig); if (!mounted) return; setState(() { @@ -1123,6 +1252,7 @@ class _ScheduleGeneratorPanelState } Widget _buildDraftList(BuildContext context) { + final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; return ListView.separated( primary: false, itemCount: _draftSchedules.length, @@ -1153,7 +1283,7 @@ class _ScheduleGeneratorPanelState crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${_shiftLabel(draft.shiftType)} · $userLabel', + '${_shiftLabel(draft.shiftType, rotationConfig)} · $userLabel', style: Theme.of(context).textTheme.bodyMedium ?.copyWith(fontWeight: FontWeight.w600), ), @@ -1219,6 +1349,7 @@ class _ScheduleGeneratorPanelState _startDate ?? AppTime.now(), _endDate ?? AppTime.now(), _draftSchedules, + ref.read(rotationConfigProvider).valueOrNull, ); }); } @@ -1400,6 +1531,7 @@ class _ScheduleGeneratorPanelState _startDate ?? result.startTime, _endDate ?? result.endTime, _draftSchedules, + ref.read(rotationConfigProvider).valueOrNull, ); }); } @@ -1506,8 +1638,22 @@ class _ScheduleGeneratorPanelState return {for (final profile in profiles) profile.id: profile}; } - Map _buildTemplates(List schedules) { + Map _buildTemplates( + List schedules, + List shiftTypes, + ) { final templates = {}; + + // Add configured shift type templates first (source of truth). + for (final shiftType in shiftTypes) { + templates[shiftType.id] = _ShiftTemplate( + startHour: shiftType.startHour, + startMinute: shiftType.startMinute, + duration: shiftType.duration, + ); + } + + // Fall back to observed schedule patterns for any missing shift types. for (final schedule in schedules) { final key = _normalizeShiftType(schedule.shiftType); if (templates.containsKey(key)) continue; @@ -1522,51 +1668,7 @@ class _ScheduleGeneratorPanelState duration: end.difference(start), ); } - templates['am'] = _ShiftTemplate( - startHour: 7, - startMinute: 0, - duration: const Duration(hours: 8), - ); - templates['pm'] = _ShiftTemplate( - startHour: 15, - startMinute: 0, - duration: const Duration(hours: 8), - ); - templates['on_call'] = _ShiftTemplate( - startHour: 23, - startMinute: 0, - duration: const Duration(hours: 8), - ); - // Weekend Saturday on_call: 5PM (17:00) to 8AM (08:00) next day = 15 hours - templates['on_call_saturday'] = _ShiftTemplate( - startHour: 17, - startMinute: 0, - duration: const Duration(hours: 15), - ); - // Weekend Sunday on_call: 5PM (17:00) to 7AM (07:00) next day = 14 hours - templates['on_call_sunday'] = _ShiftTemplate( - startHour: 17, - startMinute: 0, - duration: const Duration(hours: 14), - ); - // Default normal shift (8am-5pm = 9 hours) - templates['normal'] = _ShiftTemplate( - startHour: 8, - startMinute: 0, - duration: const Duration(hours: 9), - ); - // Islam Ramadan normal shift (8am-4pm = 8 hours) - templates['normal_ramadan_islam'] = _ShiftTemplate( - startHour: 8, - startMinute: 0, - duration: const Duration(hours: 8), - ); - // Non-Islam Ramadan normal shift (8am-5pm = 9 hours, same as default) - templates['normal_ramadan_other'] = _ShiftTemplate( - startHour: 8, - startMinute: 0, - duration: const Duration(hours: 9), - ); + return templates; } @@ -1584,6 +1686,91 @@ class _ScheduleGeneratorPanelState final existing = schedules; final excludedIds = rotationConfig?.excludedStaffIds ?? []; + final shiftTypes = + rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes; + final roleWeeklyHours = rotationConfig?.roleWeeklyHours ?? {}; + final holidayDates = (rotationConfig?.holidays ?? []) + .map((h) => DateTime(h.date.year, h.date.month, h.date.day)) + .toSet(); + + final userRoles = {for (final p in staff) p.id: p.role}; + + bool isShiftTypeAllowedForRole(String shiftTypeId, String role) { + final config = shiftTypes.firstWhere( + (s) => s.id == shiftTypeId, + orElse: () => ShiftTypeConfig( + id: shiftTypeId, + label: shiftTypeId, + startHour: 0, + startMinute: 0, + durationMinutes: 0, + allowedRoles: const [], + ), + ); + return config.allowedRoles.isEmpty || config.allowedRoles.contains(role); + } + + int minutesInWeekForUser( + String userId, + DateTime weekStart, + DateTime weekEnd, + ) { + int workMinutesForSchedule(dynamic schedule) { + final duration = schedule.endTime.difference(schedule.startTime); + final shiftType = shiftTypes.firstWhere( + (s) => s.id == schedule.shiftType, + orElse: () => ShiftTypeConfig( + id: schedule.shiftType, + label: schedule.shiftType, + startHour: 0, + startMinute: 0, + durationMinutes: duration.inMinutes, + ), + ); + final breakMinutes = shiftType.noonBreakMinutes; + return (duration.inMinutes - breakMinutes) + .clamp(0, duration.inMinutes) + .toInt(); + } + + var total = 0; + for (final schedule in existing) { + if (schedule.userId != userId) continue; + if (schedule.startTime.isBefore(weekStart) || + schedule.startTime.isAfter(weekEnd)) { + continue; + } + total += workMinutesForSchedule(schedule); + } + for (final item in draft) { + if (item.userId != userId) continue; + if (item.startTime.isBefore(weekStart) || + item.startTime.isAfter(weekEnd)) { + continue; + } + total += workMinutesForSchedule(item); + } + return total; + } + + bool canAddShift( + String userId, + String shiftTypeId, + int shiftMinutes, + DateTime weekStart, + DateTime weekEnd, + ) { + final role = userRoles[userId]; + if (role == null) return true; + if (!isShiftTypeAllowedForRole(shiftTypeId, role)) { + return false; + } + final capHours = roleWeeklyHours[role]; + if (capHours == null || capHours <= 0) return true; + final capMinutes = capHours * 60; + final currentMinutes = minutesInWeekForUser(userId, weekStart, weekEnd); + return currentMinutes + shiftMinutes <= capMinutes; + } // Only IT Staff rotate through AM/PM/on_call shifts (minus excluded) final allItStaff = staff.where((p) => p.role == 'it_staff').toList(); @@ -1706,6 +1893,39 @@ class _ScheduleGeneratorPanelState : itStaff[pmBaseIndex % itStaff.length].id; final pmRelievers = _buildRelievers(pmBaseIndex, itStaff); + void tryAddShift( + String shiftType, + String userId, + DateTime day, + List relieverIds, { + String? displayShiftType, + required bool isHoliday, + }) { + final template = templates[_normalizeShiftType(shiftType)]; + if (template == null) return; + final durationMinutes = template.duration.inMinutes; + if (!canAddShift( + userId, + shiftType, + durationMinutes, + weekStart, + weekEnd, + )) { + return; + } + _tryAddDraft( + draft, + existing, + templates, + shiftType, + userId, + day, + relieverIds, + displayShiftType: displayShiftType, + isHoliday: isHoliday, + ); + } + for ( var day = weekStart; !day.isAfter(weekEnd); @@ -1717,53 +1937,48 @@ class _ScheduleGeneratorPanelState final isWeekend = day.weekday == DateTime.saturday || day.weekday == DateTime.sunday; final dayIsRamadan = isApproximateRamadan(day); + final isHoliday = holidayDates.contains( + DateTime(day.year, day.month, day.day), + ); if (isWeekend) { final isSaturday = day.weekday == DateTime.saturday; if (isSaturday) { // Saturday: AM person gets normal shift, PM person gets weekend on_call if (amUserId != null) { - _tryAddDraft( - draft, - existing, - templates, + tryAddShift( 'normal', amUserId, day, const [], + isHoliday: isHoliday, ); } if (pmUserId != null) { - _tryAddDraft( - draft, - existing, - templates, + tryAddShift( 'on_call_saturday', pmUserId, day, pmRelievers, + isHoliday: isHoliday, ); } } else { // Sunday: PM person gets both normal and weekend on_call if (pmUserId != null) { - _tryAddDraft( - draft, - existing, - templates, + tryAddShift( 'normal', pmUserId, day, const [], + isHoliday: isHoliday, ); - _tryAddDraft( - draft, - existing, - templates, + tryAddShift( 'on_call_sunday', pmUserId, day, pmRelievers, + isHoliday: isHoliday, ); } } @@ -1791,34 +2006,22 @@ class _ScheduleGeneratorPanelState } if (effectiveAmUserId != null) { - _tryAddDraft( - draft, - existing, - templates, + tryAddShift( 'am', effectiveAmUserId, day, const [], + isHoliday: isHoliday, ); } if (pmUserId != null) { - _tryAddDraft( - draft, - existing, - templates, - 'pm', - pmUserId, - day, - pmRelievers, - ); - _tryAddDraft( - draft, - existing, - templates, + tryAddShift('pm', pmUserId, day, pmRelievers, isHoliday: isHoliday); + tryAddShift( 'on_call', pmUserId, day, pmRelievers, + isHoliday: isHoliday, ); } @@ -1834,34 +2037,69 @@ class _ScheduleGeneratorPanelState : dayIsRamadan ? 'normal_ramadan_other' : 'normal'; - _tryAddDraft( - draft, - existing, - templates, + tryAddShift( normalKey, profile.id, day, const [], displayShiftType: 'normal', + isHoliday: isHoliday, ); } - // Admin/Dispatcher always get normal shift (no rotation) + // Admin/Dispatcher always get a role-specific shift (no rotation). + String shiftTypeForRole(Profile profile) { + // Exclude IT rotation shift types; those are handled above. + const itRotationIds = { + 'am', + 'pm', + 'on_call', + 'on_call_saturday', + 'on_call_sunday', + }; + + final candidates = shiftTypes.where((s) { + if (itRotationIds.contains(s.id)) return false; + if (s.allowedRoles.isNotEmpty && + !s.allowedRoles.contains(profile.role)) { + return false; + } + return true; + }).toList(); + + if (candidates.isEmpty) { + // Fallback to the old "normal" behavior. + if (dayIsRamadan) { + return profile.religion == 'islam' + ? 'normal_ramadan_islam' + : 'normal_ramadan_other'; + } + return 'normal'; + } + + if (dayIsRamadan) { + final r = profile.religion == 'islam' + ? 'ramadan_islam' + : 'ramadan_other'; + final match = candidates.firstWhere( + (s) => s.id.contains(r), + orElse: () => candidates.first, + ); + return match.id; + } + + return candidates.first.id; + } + for (final profile in nonRotating) { - final normalKey = dayIsRamadan && profile.religion == 'islam' - ? 'normal_ramadan_islam' - : dayIsRamadan - ? 'normal_ramadan_other' - : 'normal'; - _tryAddDraft( - draft, - existing, - templates, - normalKey, + final shiftKey = shiftTypeForRole(profile); + tryAddShift( + shiftKey, profile.id, day, const [], - displayShiftType: 'normal', + displayShiftType: shiftKey, + isHoliday: isHoliday, ); } } @@ -1882,6 +2120,7 @@ class _ScheduleGeneratorPanelState DateTime day, List relieverIds, { String? displayShiftType, + bool isHoliday = false, }) { final template = templates[_normalizeShiftType(shiftType)]!; final start = template.buildStart(day); @@ -1892,6 +2131,7 @@ class _ScheduleGeneratorPanelState shiftType: displayShiftType ?? shiftType, startTime: start, endTime: end, + isHoliday: isHoliday, relieverIds: relieverIds, ); @@ -1997,6 +2237,7 @@ class _ScheduleGeneratorPanelState DateTime start, DateTime end, List<_DraftSchedule> drafts, + RotationConfig? rotationConfig, ) { final warnings = []; var day = DateTime(start.year, start.month, start.day); @@ -2025,7 +2266,7 @@ class _ScheduleGeneratorPanelState for (final shift in required) { if (!available.contains(shift)) { warnings.add( - '${AppTime.formatDate(day)} missing ${_shiftLabel(shift)}', + '${AppTime.formatDate(day)} missing ${_shiftLabel(shift, rotationConfig)}', ); } } @@ -2044,7 +2285,21 @@ class _ScheduleGeneratorPanelState return value; } - String _shiftLabel(String value) { + String _shiftLabel(String value, RotationConfig? rotationConfig) { + final configured = rotationConfig?.shiftTypes.firstWhere( + (s) => s.id == value, + orElse: () => ShiftTypeConfig( + id: value, + label: value, + startHour: 0, + startMinute: 0, + durationMinutes: 0, + ), + ); + if (configured != null && configured.label.isNotEmpty) { + return configured.label; + } + switch (value) { case 'am': return 'AM Duty'; @@ -2072,6 +2327,7 @@ class _SwapRequestsPanel extends ConsumerWidget { final swapsAsync = ref.watch(swapRequestsProvider); final schedulesAsync = ref.watch(dutySchedulesProvider); final profilesAsync = ref.watch(profilesProvider); + final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; final currentUserId = ref.watch(currentUserIdProvider); final Map scheduleById = { @@ -2132,13 +2388,13 @@ class _SwapRequestsPanel extends ConsumerWidget { String subtitle; if (requesterSchedule != null && targetSchedule != null) { subtitle = - '${_shiftLabel(requesterSchedule.shiftType)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)} → ${_shiftLabel(targetSchedule.shiftType)} · ${AppTime.formatDate(targetSchedule.startTime)} · ${AppTime.formatTime(targetSchedule.startTime)}'; + '${_shiftLabel(requesterSchedule.shiftType, rotationConfig)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)} → ${_shiftLabel(targetSchedule.shiftType, rotationConfig)} · ${AppTime.formatDate(targetSchedule.startTime)} · ${AppTime.formatTime(targetSchedule.startTime)}'; } else if (requesterSchedule != null) { subtitle = - '${_shiftLabel(requesterSchedule.shiftType)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)}'; + '${_shiftLabel(requesterSchedule.shiftType, rotationConfig)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)}'; } else if (item.shiftStartTime != null) { subtitle = - '${_shiftLabel(item.shiftType ?? 'normal')} · ${AppTime.formatDate(item.shiftStartTime!)} · ${AppTime.formatTime(item.shiftStartTime!)}'; + '${_shiftLabel(item.shiftType ?? 'normal', rotationConfig)} · ${AppTime.formatDate(item.shiftStartTime!)} · ${AppTime.formatTime(item.shiftStartTime!)}'; } else { subtitle = 'Shift not found'; } @@ -2316,7 +2572,21 @@ class _SwapRequestsPanel extends ConsumerWidget { ref.invalidate(swapRequestsProvider); } - String _shiftLabel(String value) { + String _shiftLabel(String value, RotationConfig? rotationConfig) { + final configured = rotationConfig?.shiftTypes.firstWhere( + (s) => s.id == value, + orElse: () => ShiftTypeConfig( + id: value, + label: value, + startHour: 0, + startMinute: 0, + durationMinutes: 0, + ), + ); + if (configured != null && configured.label.isNotEmpty) { + return configured.label; + } + switch (value) { case 'am': return 'AM Duty'; diff --git a/lib/services/holidays_service.dart b/lib/services/holidays_service.dart new file mode 100644 index 00000000..c7df8eeb --- /dev/null +++ b/lib/services/holidays_service.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/rotation_config.dart'; + +/// Provides holiday lookup via Nager.Date. +class HolidaysService { + /// Fetches Philippine public holidays for [year]. + /// + /// Returns a list of [Holiday] instances. + static Future> fetchPhilippinesHolidays(int year) async { + final uri = Uri.https('date.nager.at', '/api/v3/PublicHolidays/$year/PH'); + final response = await http.get(uri); + if (response.statusCode != 200) { + throw Exception('Failed to fetch holidays: ${response.statusCode}'); + } + + final decoded = jsonDecode(response.body); + if (decoded is! List) { + throw Exception('Unexpected holiday response format'); + } + + return decoded + .whereType>() + .map((item) { + final dateString = item['date'] as String?; + final name = + item['localName'] as String? ?? + item['name'] as String? ?? + 'Holiday'; + + if (dateString == null) { + return null; + } + + final parsed = DateTime.tryParse(dateString); + if (parsed == null) { + return null; + } + + return Holiday( + date: DateTime(parsed.year, parsed.month, parsed.day), + name: name, + source: 'nager', + ); + }) + .whereType() + .toList(); + } +} diff --git a/supabase/migrations/20260318190000_rotation_config_defaults.sql b/supabase/migrations/20260318190000_rotation_config_defaults.sql new file mode 100644 index 00000000..1e752830 --- /dev/null +++ b/supabase/migrations/20260318190000_rotation_config_defaults.sql @@ -0,0 +1,31 @@ +-- Ensure rotation_config has new defaults without overwriting existing custom values. +-- This migration is idempotent: rerunning it does not change already-set values. + +-- Ensure the row exists (older installs may not have created it yet). +INSERT INTO app_settings (key, value) +VALUES ('rotation_config', '{}'::jsonb) +ON CONFLICT (key) DO NOTHING; + +-- Add missing keys (only) using a conditional merge. +UPDATE app_settings +SET value = value || ( + (CASE + WHEN value ? 'shift_types' THEN '{}'::jsonb + ELSE jsonb_build_object( + 'shift_types', + '[{"id":"am","label":"AM Duty","start_hour":7,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]}, + {"id":"pm","label":"PM Duty","start_hour":15,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]}, + {"id":"on_call","label":"On Call","start_hour":23,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]}, + {"id":"on_call_saturday","label":"On Call (Saturday)","start_hour":17,"start_minute":0,"duration_minutes":900,"allowed_roles":["it_staff"]}, + {"id":"on_call_sunday","label":"On Call (Sunday)","start_hour":17,"start_minute":0,"duration_minutes":840,"allowed_roles":["it_staff"]}, + {"id":"normal","label":"Normal","start_hour":8,"start_minute":0,"duration_minutes":540,"allowed_roles":["admin","dispatcher","programmer","it_staff"]}, + {"id":"normal_ramadan_islam","label":"Normal (Ramadan - Islam)","start_hour":8,"start_minute":0,"duration_minutes":480,"allowed_roles":["admin","dispatcher","programmer","it_staff"]}, + {"id":"normal_ramadan_other","label":"Normal (Ramadan - Other)","start_hour":8,"start_minute":0,"duration_minutes":540,"allowed_roles":["admin","dispatcher","programmer","it_staff"]}]'::jsonb + ) + END) + || (CASE WHEN value ? 'role_weekly_hours' THEN '{}'::jsonb ELSE jsonb_build_object('role_weekly_hours', '{}'::jsonb) END) + || (CASE WHEN value ? 'holidays' THEN '{}'::jsonb ELSE jsonb_build_object('holidays', '[]'::jsonb) END) + || (CASE WHEN value ? 'sync_philippines_holidays' THEN '{}'::jsonb ELSE jsonb_build_object('sync_philippines_holidays', false) END) + || (CASE WHEN value ? 'holidays_year' THEN '{}'::jsonb ELSE jsonb_build_object('holidays_year', NULL) END) +) +WHERE key = 'rotation_config'; diff --git a/supabase/migrations/20260318191000_rotation_config_merge_shift_types.sql b/supabase/migrations/20260318191000_rotation_config_merge_shift_types.sql new file mode 100644 index 00000000..23f306b1 --- /dev/null +++ b/supabase/migrations/20260318191000_rotation_config_merge_shift_types.sql @@ -0,0 +1,59 @@ +-- Merge stored shift_types with defaults so new default shift types automatically appear +-- without overwriting existing customizations. + +UPDATE app_settings +SET value = jsonb_set( + value, + '{shift_types}', + ( + WITH + -- Default shift types (must match RotationConfig._defaultShiftTypes()) + defaults AS ( + SELECT '[ + {"id":"am","label":"AM Duty","start_hour":7,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]}, + {"id":"pm","label":"PM Duty","start_hour":15,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]}, + {"id":"on_call","label":"On Call","start_hour":23,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]}, + {"id":"on_call_saturday","label":"On Call (Saturday)","start_hour":17,"start_minute":0,"duration_minutes":900,"allowed_roles":["it_staff"]}, + {"id":"on_call_sunday","label":"On Call (Sunday)","start_hour":17,"start_minute":0,"duration_minutes":840,"allowed_roles":["it_staff"]}, + {"id":"normal","label":"Normal","start_hour":8,"start_minute":0,"duration_minutes":540,"allowed_roles":["admin","dispatcher","programmer","it_staff"]}, + {"id":"normal_ramadan_islam","label":"Normal (Ramadan - Islam)","start_hour":8,"start_minute":0,"duration_minutes":480,"allowed_roles":["admin","dispatcher","programmer","it_staff"]}, + {"id":"normal_ramadan_other","label":"Normal (Ramadan - Other)","start_hour":8,"start_minute":0,"duration_minutes":540,"allowed_roles":["admin","dispatcher","programmer","it_staff"]} + ]'::jsonb AS val + ), + stored AS ( + SELECT value->'shift_types' AS val + FROM app_settings + WHERE key = 'rotation_config' + ) + SELECT + -- Start with defaults, overriding with stored by id + ( + SELECT jsonb_agg(COALESCE(stored_elem_val, default_elem)) + FROM defaults, stored, + LATERAL jsonb_array_elements(defaults.val) default_elem + LEFT JOIN LATERAL ( + SELECT s AS stored_elem_val + FROM jsonb_array_elements(stored.val) s + WHERE s->>'id' = default_elem->>'id' + LIMIT 1 + ) stored_elem ON true + ) + -- Append any stored entries that aren't in defaults + || COALESCE( + ( + SELECT jsonb_agg(s) + FROM stored, + jsonb_array_elements(stored.val) s + WHERE NOT EXISTS ( + SELECT 1 + FROM defaults, + LATERAL jsonb_array_elements(defaults.val) d + WHERE d->>'id' = s->>'id' + ) + ), + '[]'::jsonb + ) + ) +) +WHERE key = 'rotation_config' + AND (value ? 'shift_types');