Added configurable shift types and holiday settings

This commit is contained in:
Marc Rejohn Castillano 2026-03-18 16:51:34 +08:00
parent 4eaf9444f0
commit 3af7a1e348
7 changed files with 1463 additions and 138 deletions

View File

@ -5,16 +5,23 @@
/// - Ordered list of non-Islam IT Staff IDs for Friday AM rotation /// - Ordered list of non-Islam IT Staff IDs for Friday AM rotation
/// - Set of excluded IT Staff IDs /// - Set of excluded IT Staff IDs
/// - Initial AM and PM duty assignments for the next generated schedule /// - 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 { class RotationConfig {
RotationConfig({ RotationConfig({
List<String>? rotationOrder, this.rotationOrder = const [],
List<String>? fridayAmOrder, this.fridayAmOrder = const [],
List<String>? excludedStaffIds, this.excludedStaffIds = const [],
this.initialAmStaffId, this.initialAmStaffId,
this.initialPmStaffId, this.initialPmStaffId,
}) : rotationOrder = rotationOrder ?? [], List<ShiftTypeConfig>? shiftTypes,
fridayAmOrder = fridayAmOrder ?? [], Map<String, int>? roleWeeklyHours,
excludedStaffIds = excludedStaffIds ?? []; List<Holiday>? holidays,
this.syncPhilippinesHolidays = false,
this.holidaysYear,
}) : shiftTypes = shiftTypes ?? _defaultShiftTypes(),
roleWeeklyHours = roleWeeklyHours ?? {},
holidays = holidays ?? [];
/// Ordered IDs for standard AM/PM rotation. /// Ordered IDs for standard AM/PM rotation.
final List<String> rotationOrder; final List<String> rotationOrder;
@ -33,6 +40,21 @@ class RotationConfig {
/// generated schedule. `null` means "continue from last week". /// generated schedule. `null` means "continue from last week".
final String? initialPmStaffId; 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) { factory RotationConfig.fromJson(Map<String, dynamic> json) {
return RotationConfig( return RotationConfig(
rotationOrder: _toStringList(json['rotation_order']), rotationOrder: _toStringList(json['rotation_order']),
@ -40,6 +62,12 @@ class RotationConfig {
excludedStaffIds: _toStringList(json['excluded_staff_ids']), excludedStaffIds: _toStringList(json['excluded_staff_ids']),
initialAmStaffId: json['initial_am_staff_id'] as String?, initialAmStaffId: json['initial_am_staff_id'] as String?,
initialPmStaffId: json['initial_pm_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, 'excluded_staff_ids': excludedStaffIds,
'initial_am_staff_id': initialAmStaffId, 'initial_am_staff_id': initialAmStaffId,
'initial_pm_staff_id': initialPmStaffId, '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({ RotationConfig copyWith({
@ -59,6 +92,11 @@ class RotationConfig {
String? initialPmStaffId, String? initialPmStaffId,
bool clearInitialAm = false, bool clearInitialAm = false,
bool clearInitialPm = false, bool clearInitialPm = false,
List<ShiftTypeConfig>? shiftTypes,
Map<String, int>? roleWeeklyHours,
List<Holiday>? holidays,
bool? syncPhilippinesHolidays,
int? holidaysYear,
}) { }) {
return RotationConfig( return RotationConfig(
rotationOrder: rotationOrder ?? this.rotationOrder, rotationOrder: rotationOrder ?? this.rotationOrder,
@ -70,11 +108,224 @@ class RotationConfig {
initialPmStaffId: clearInitialPm initialPmStaffId: clearInitialPm
? null ? null
: (initialPmStaffId ?? this.initialPmStaffId), : (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) { static List<String> _toStringList(dynamic value) {
if (value is List) return value.map((e) => e.toString()).toList(); if (value is List) return value.map((e) => e.toString()).toList();
return []; 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?,
);
}
} }

View File

@ -48,4 +48,34 @@ class RotationConfigController {
}); });
_ref.invalidate(rotationConfigProvider); _ref.invalidate(rotationConfigProvider);
} }
Future<void> updateShiftTypes(List<ShiftTypeConfig> shiftTypes) async {
final config = await _ref.read(rotationConfigProvider.future);
await save(config.copyWith(shiftTypes: shiftTypes));
}
Future<void> updateRoleWeeklyHours(Map<String, int> roleWeeklyHours) async {
final config = await _ref.read(rotationConfigProvider.future);
await save(config.copyWith(roleWeeklyHours: roleWeeklyHours));
}
Future<void> updateHolidays(List<Holiday> holidays) async {
final config = await _ref.read(rotationConfigProvider.future);
await save(config.copyWith(holidays: holidays));
}
Future<void> setHolidaySync({
required bool enabled,
required int year,
required List<Holiday> holidays,
}) async {
final config = await _ref.read(rotationConfigProvider.future);
await save(
config.copyWith(
syncPhilippinesHolidays: enabled,
holidaysYear: year,
holidays: holidays,
),
);
}
} }

View File

@ -7,6 +7,8 @@ import '../../providers/profile_provider.dart';
import '../../providers/rotation_config_provider.dart'; import '../../providers/rotation_config_provider.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import '../../services/holidays_service.dart';
import '../../utils/app_time.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
/// Opens rotation settings as a dialog (desktop/tablet) or bottom sheet /// Opens rotation settings as a dialog (desktop/tablet) or bottom sheet
@ -53,7 +55,7 @@ class _RotationSettingsDialogState
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabCtrl = TabController(length: 4, vsync: this); _tabCtrl = TabController(length: 6, vsync: this);
} }
@override @override
@ -83,6 +85,8 @@ class _RotationSettingsDialogState
Tab(text: 'Friday AM'), Tab(text: 'Friday AM'),
Tab(text: 'Excluded'), Tab(text: 'Excluded'),
Tab(text: 'Initial Duty'), Tab(text: 'Initial Duty'),
Tab(text: 'Shift Types'),
Tab(text: 'Holidays'),
], ],
), ),
Flexible( Flexible(
@ -93,6 +97,8 @@ class _RotationSettingsDialogState
_FridayAmTab(), _FridayAmTab(),
_ExcludedTab(), _ExcludedTab(),
_InitialDutyTab(), _InitialDutyTab(),
_ShiftTypesTab(),
_HolidaySettingsTab(),
], ],
), ),
), ),
@ -148,7 +154,7 @@ class _RotationSettingsSheetState extends ConsumerState<_RotationSettingsSheet>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabCtrl = TabController(length: 4, vsync: this); _tabCtrl = TabController(length: 6, vsync: this);
} }
@override @override
@ -192,6 +198,8 @@ class _RotationSettingsSheetState extends ConsumerState<_RotationSettingsSheet>
Tab(text: 'Friday AM'), Tab(text: 'Friday AM'),
Tab(text: 'Excluded'), Tab(text: 'Excluded'),
Tab(text: 'Initial Duty'), Tab(text: 'Initial Duty'),
Tab(text: 'Shift Types'),
Tab(text: 'Holidays'),
], ],
), ),
Expanded( Expanded(
@ -202,6 +210,8 @@ class _RotationSettingsSheetState extends ConsumerState<_RotationSettingsSheet>
_FridayAmTab(), _FridayAmTab(),
_ExcludedTab(), _ExcludedTab(),
_InitialDutyTab(), _InitialDutyTab(),
_ShiftTypesTab(),
_HolidaySettingsTab(),
], ],
), ),
), ),
@ -795,20 +805,643 @@ class _SaveButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox( child: FilledButton.icon(
width: double.infinity, onPressed: saving ? null : onSave,
child: FilledButton.icon( icon: saving
onPressed: saving ? null : onSave, ? const SizedBox(
icon: saving height: 18,
? const SizedBox( width: 18,
height: 18, child: CircularProgressIndicator(strokeWidth: 2),
width: 18, )
child: CircularProgressIndicator(strokeWidth: 2), : const Icon(Icons.save_outlined),
) label: const Text('Save'),
: 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<ShiftTypeConfig>? _shiftTypes;
Map<String, int> _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<String>.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<void> _addShiftType() async {
final newShift = ShiftTypeConfig(
id: 'new_shift_${DateTime.now().millisecondsSinceEpoch}',
label: 'New Shift',
startHour: 8,
startMinute: 0,
durationMinutes: 480,
noonBreakMinutes: 60,
allowedRoles: <String>[],
);
setState(() {
_shiftTypes = [...?_shiftTypes, newShift];
});
await _editShiftType(context, _shiftTypes!.length - 1);
}
Future<void> _removeShiftType(int index) async {
setState(() {
_shiftTypes = [...?_shiftTypes]..removeAt(index);
});
}
Future<void> _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<bool>(
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<void> _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<Holiday> _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<void> _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<void> _addCustomHoliday() async {
DateTime selectedDate = DateTime.now();
String name = '';
final confirmed = await m3ShowDialog<bool>(
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<void> _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);
}
}
}

View File

@ -97,6 +97,7 @@ class _SchedulePanel extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final schedulesAsync = ref.watch(dutySchedulesProvider); final schedulesAsync = ref.watch(dutySchedulesProvider);
final profilesAsync = ref.watch(profilesProvider); final profilesAsync = ref.watch(profilesProvider);
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
final currentUserId = ref.watch(currentUserIdProvider); final currentUserId = ref.watch(currentUserIdProvider);
final showPast = ref.watch(showPastSchedulesProvider); final showPast = ref.watch(showPastSchedulesProvider);
@ -183,6 +184,7 @@ class _SchedulePanel extends ConsumerWidget {
profileById, profileById,
schedule, schedule,
isAdmin, isAdmin,
rotationConfig,
), ),
relieverLabels: _relieverLabelsFromIds( relieverLabels: _relieverLabelsFromIds(
schedule.relieverIds, schedule.relieverIds,
@ -211,12 +213,13 @@ class _SchedulePanel extends ConsumerWidget {
Map<String, Profile> profileById, Map<String, Profile> profileById,
DutySchedule schedule, DutySchedule schedule,
bool isAdmin, bool isAdmin,
RotationConfig? rotationConfig,
) { ) {
final profile = profileById[schedule.userId]; final profile = profileById[schedule.userId];
final name = profile?.fullName.isNotEmpty == true final name = profile?.fullName.isNotEmpty == true
? profile!.fullName ? profile!.fullName
: schedule.userId; : schedule.userId;
return '${_shiftLabel(schedule.shiftType)} · $name'; return '${_shiftLabel(schedule.shiftType, rotationConfig)} · $name';
} }
static String _dayOfWeek(DateTime day) { static String _dayOfWeek(DateTime day) {
@ -224,7 +227,21 @@ class _SchedulePanel extends ConsumerWidget {
return days[day.weekday - 1]; 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) { switch (value) {
case 'am': case 'am':
return 'AM Duty'; return 'AM Duty';
@ -285,6 +302,42 @@ class _ScheduleTile extends ConsumerWidget {
); );
final canRequestSwap = isMine && schedule.status != 'absent' && !isPast; 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( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@ -314,6 +367,48 @@ class _ScheduleTile extends ConsumerWidget {
color: _statusColor(context, schedule.status), 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 startTime = TimeOfDay.fromDateTime(schedule.startTime);
var endTime = TimeOfDay.fromDateTime(schedule.endTime); var endTime = TimeOfDay.fromDateTime(schedule.endTime);
final rotationConfig = ref.read(rotationConfigProvider).valueOrNull;
final shiftTypeConfigs =
rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes;
final confirmed = await m3ShowDialog<bool>( final confirmed = await m3ShowDialog<bool>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return StatefulBuilder( return StatefulBuilder(
builder: (context, setState) { 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( return AlertDialog(
shape: AppSurfaces.of(context).dialogShape, shape: AppSurfaces.of(context).dialogShape,
title: const Text('Edit Schedule'), title: const Text('Edit Schedule'),
@ -420,17 +550,12 @@ class _ScheduleTile extends ConsumerWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: selectedShift, initialValue: selectedShift,
items: const [ items: [
DropdownMenuItem(value: 'am', child: Text('AM Duty')), for (final type in allowed)
DropdownMenuItem(value: 'pm', child: Text('PM Duty')), DropdownMenuItem(
DropdownMenuItem( value: type.id,
value: 'on_call', child: Text(type.label),
child: Text('On Call'), ),
),
DropdownMenuItem(
value: 'normal',
child: Text('Normal'),
),
], ],
onChanged: (v) { onChanged: (v) {
if (v != null) setState(() => selectedShift = v); if (v != null) setState(() => selectedShift = v);
@ -784,6 +909,7 @@ class _DraftSchedule {
required this.shiftType, required this.shiftType,
required this.startTime, required this.startTime,
required this.endTime, required this.endTime,
this.isHoliday = false,
List<String>? relieverIds, List<String>? relieverIds,
}) : relieverIds = relieverIds ?? <String>[]; }) : relieverIds = relieverIds ?? <String>[];
@ -793,6 +919,7 @@ class _DraftSchedule {
DateTime startTime; DateTime startTime;
DateTime endTime; DateTime endTime;
List<String> relieverIds; List<String> relieverIds;
bool isHoliday;
} }
class _RotationEntry { class _RotationEntry {
@ -1027,7 +1154,9 @@ class _ScheduleGeneratorPanelState
final rotationConfig = ref.read(rotationConfigProvider).valueOrNull; final rotationConfig = ref.read(rotationConfigProvider).valueOrNull;
final schedules = ref.read(dutySchedulesProvider).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( final generated = _generateDrafts(
start, start,
end, end,
@ -1037,7 +1166,7 @@ class _ScheduleGeneratorPanelState
rotationConfig: rotationConfig, rotationConfig: rotationConfig,
); );
generated.sort((a, b) => a.startTime.compareTo(b.startTime)); 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; if (!mounted) return;
setState(() { setState(() {
@ -1123,6 +1252,7 @@ class _ScheduleGeneratorPanelState
} }
Widget _buildDraftList(BuildContext context) { Widget _buildDraftList(BuildContext context) {
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
return ListView.separated( return ListView.separated(
primary: false, primary: false,
itemCount: _draftSchedules.length, itemCount: _draftSchedules.length,
@ -1153,7 +1283,7 @@ class _ScheduleGeneratorPanelState
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'${_shiftLabel(draft.shiftType)} · $userLabel', '${_shiftLabel(draft.shiftType, rotationConfig)} · $userLabel',
style: Theme.of(context).textTheme.bodyMedium style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600), ?.copyWith(fontWeight: FontWeight.w600),
), ),
@ -1219,6 +1349,7 @@ class _ScheduleGeneratorPanelState
_startDate ?? AppTime.now(), _startDate ?? AppTime.now(),
_endDate ?? AppTime.now(), _endDate ?? AppTime.now(),
_draftSchedules, _draftSchedules,
ref.read(rotationConfigProvider).valueOrNull,
); );
}); });
} }
@ -1400,6 +1531,7 @@ class _ScheduleGeneratorPanelState
_startDate ?? result.startTime, _startDate ?? result.startTime,
_endDate ?? result.endTime, _endDate ?? result.endTime,
_draftSchedules, _draftSchedules,
ref.read(rotationConfigProvider).valueOrNull,
); );
}); });
} }
@ -1506,8 +1638,22 @@ class _ScheduleGeneratorPanelState
return {for (final profile in profiles) profile.id: profile}; return {for (final profile in profiles) profile.id: profile};
} }
Map<String, _ShiftTemplate> _buildTemplates(List<DutySchedule> schedules) { Map<String, _ShiftTemplate> _buildTemplates(
List<DutySchedule> schedules,
List<ShiftTypeConfig> shiftTypes,
) {
final templates = <String, _ShiftTemplate>{}; final templates = <String, _ShiftTemplate>{};
// 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) { for (final schedule in schedules) {
final key = _normalizeShiftType(schedule.shiftType); final key = _normalizeShiftType(schedule.shiftType);
if (templates.containsKey(key)) continue; if (templates.containsKey(key)) continue;
@ -1522,51 +1668,7 @@ class _ScheduleGeneratorPanelState
duration: end.difference(start), 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; return templates;
} }
@ -1584,6 +1686,91 @@ class _ScheduleGeneratorPanelState
final existing = schedules; final existing = schedules;
final excludedIds = rotationConfig?.excludedStaffIds ?? []; 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) // Only IT Staff rotate through AM/PM/on_call shifts (minus excluded)
final allItStaff = staff.where((p) => p.role == 'it_staff').toList(); final allItStaff = staff.where((p) => p.role == 'it_staff').toList();
@ -1706,6 +1893,39 @@ class _ScheduleGeneratorPanelState
: itStaff[pmBaseIndex % itStaff.length].id; : itStaff[pmBaseIndex % itStaff.length].id;
final pmRelievers = _buildRelievers(pmBaseIndex, itStaff); final pmRelievers = _buildRelievers(pmBaseIndex, itStaff);
void tryAddShift(
String shiftType,
String userId,
DateTime day,
List<String> 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 ( for (
var day = weekStart; var day = weekStart;
!day.isAfter(weekEnd); !day.isAfter(weekEnd);
@ -1717,53 +1937,48 @@ class _ScheduleGeneratorPanelState
final isWeekend = final isWeekend =
day.weekday == DateTime.saturday || day.weekday == DateTime.sunday; day.weekday == DateTime.saturday || day.weekday == DateTime.sunday;
final dayIsRamadan = isApproximateRamadan(day); final dayIsRamadan = isApproximateRamadan(day);
final isHoliday = holidayDates.contains(
DateTime(day.year, day.month, day.day),
);
if (isWeekend) { if (isWeekend) {
final isSaturday = day.weekday == DateTime.saturday; final isSaturday = day.weekday == DateTime.saturday;
if (isSaturday) { if (isSaturday) {
// Saturday: AM person gets normal shift, PM person gets weekend on_call // Saturday: AM person gets normal shift, PM person gets weekend on_call
if (amUserId != null) { if (amUserId != null) {
_tryAddDraft( tryAddShift(
draft,
existing,
templates,
'normal', 'normal',
amUserId, amUserId,
day, day,
const [], const [],
isHoliday: isHoliday,
); );
} }
if (pmUserId != null) { if (pmUserId != null) {
_tryAddDraft( tryAddShift(
draft,
existing,
templates,
'on_call_saturday', 'on_call_saturday',
pmUserId, pmUserId,
day, day,
pmRelievers, pmRelievers,
isHoliday: isHoliday,
); );
} }
} else { } else {
// Sunday: PM person gets both normal and weekend on_call // Sunday: PM person gets both normal and weekend on_call
if (pmUserId != null) { if (pmUserId != null) {
_tryAddDraft( tryAddShift(
draft,
existing,
templates,
'normal', 'normal',
pmUserId, pmUserId,
day, day,
const [], const [],
isHoliday: isHoliday,
); );
_tryAddDraft( tryAddShift(
draft,
existing,
templates,
'on_call_sunday', 'on_call_sunday',
pmUserId, pmUserId,
day, day,
pmRelievers, pmRelievers,
isHoliday: isHoliday,
); );
} }
} }
@ -1791,34 +2006,22 @@ class _ScheduleGeneratorPanelState
} }
if (effectiveAmUserId != null) { if (effectiveAmUserId != null) {
_tryAddDraft( tryAddShift(
draft,
existing,
templates,
'am', 'am',
effectiveAmUserId, effectiveAmUserId,
day, day,
const [], const [],
isHoliday: isHoliday,
); );
} }
if (pmUserId != null) { if (pmUserId != null) {
_tryAddDraft( tryAddShift('pm', pmUserId, day, pmRelievers, isHoliday: isHoliday);
draft, tryAddShift(
existing,
templates,
'pm',
pmUserId,
day,
pmRelievers,
);
_tryAddDraft(
draft,
existing,
templates,
'on_call', 'on_call',
pmUserId, pmUserId,
day, day,
pmRelievers, pmRelievers,
isHoliday: isHoliday,
); );
} }
@ -1834,34 +2037,69 @@ class _ScheduleGeneratorPanelState
: dayIsRamadan : dayIsRamadan
? 'normal_ramadan_other' ? 'normal_ramadan_other'
: 'normal'; : 'normal';
_tryAddDraft( tryAddShift(
draft,
existing,
templates,
normalKey, normalKey,
profile.id, profile.id,
day, day,
const [], const [],
displayShiftType: 'normal', 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) { for (final profile in nonRotating) {
final normalKey = dayIsRamadan && profile.religion == 'islam' final shiftKey = shiftTypeForRole(profile);
? 'normal_ramadan_islam' tryAddShift(
: dayIsRamadan shiftKey,
? 'normal_ramadan_other'
: 'normal';
_tryAddDraft(
draft,
existing,
templates,
normalKey,
profile.id, profile.id,
day, day,
const [], const [],
displayShiftType: 'normal', displayShiftType: shiftKey,
isHoliday: isHoliday,
); );
} }
} }
@ -1882,6 +2120,7 @@ class _ScheduleGeneratorPanelState
DateTime day, DateTime day,
List<String> relieverIds, { List<String> relieverIds, {
String? displayShiftType, String? displayShiftType,
bool isHoliday = false,
}) { }) {
final template = templates[_normalizeShiftType(shiftType)]!; final template = templates[_normalizeShiftType(shiftType)]!;
final start = template.buildStart(day); final start = template.buildStart(day);
@ -1892,6 +2131,7 @@ class _ScheduleGeneratorPanelState
shiftType: displayShiftType ?? shiftType, shiftType: displayShiftType ?? shiftType,
startTime: start, startTime: start,
endTime: end, endTime: end,
isHoliday: isHoliday,
relieverIds: relieverIds, relieverIds: relieverIds,
); );
@ -1997,6 +2237,7 @@ class _ScheduleGeneratorPanelState
DateTime start, DateTime start,
DateTime end, DateTime end,
List<_DraftSchedule> drafts, List<_DraftSchedule> drafts,
RotationConfig? rotationConfig,
) { ) {
final warnings = <String>[]; final warnings = <String>[];
var day = DateTime(start.year, start.month, start.day); var day = DateTime(start.year, start.month, start.day);
@ -2025,7 +2266,7 @@ class _ScheduleGeneratorPanelState
for (final shift in required) { for (final shift in required) {
if (!available.contains(shift)) { if (!available.contains(shift)) {
warnings.add( warnings.add(
'${AppTime.formatDate(day)} missing ${_shiftLabel(shift)}', '${AppTime.formatDate(day)} missing ${_shiftLabel(shift, rotationConfig)}',
); );
} }
} }
@ -2044,7 +2285,21 @@ class _ScheduleGeneratorPanelState
return value; 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) { switch (value) {
case 'am': case 'am':
return 'AM Duty'; return 'AM Duty';
@ -2072,6 +2327,7 @@ class _SwapRequestsPanel extends ConsumerWidget {
final swapsAsync = ref.watch(swapRequestsProvider); final swapsAsync = ref.watch(swapRequestsProvider);
final schedulesAsync = ref.watch(dutySchedulesProvider); final schedulesAsync = ref.watch(dutySchedulesProvider);
final profilesAsync = ref.watch(profilesProvider); final profilesAsync = ref.watch(profilesProvider);
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
final currentUserId = ref.watch(currentUserIdProvider); final currentUserId = ref.watch(currentUserIdProvider);
final Map<String, DutySchedule> scheduleById = { final Map<String, DutySchedule> scheduleById = {
@ -2132,13 +2388,13 @@ class _SwapRequestsPanel extends ConsumerWidget {
String subtitle; String subtitle;
if (requesterSchedule != null && targetSchedule != null) { if (requesterSchedule != null && targetSchedule != null) {
subtitle = 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) { } else if (requesterSchedule != null) {
subtitle = 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) { } else if (item.shiftStartTime != null) {
subtitle = 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 { } else {
subtitle = 'Shift not found'; subtitle = 'Shift not found';
} }
@ -2316,7 +2572,21 @@ class _SwapRequestsPanel extends ConsumerWidget {
ref.invalidate(swapRequestsProvider); 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) { switch (value) {
case 'am': case 'am':
return 'AM Duty'; return 'AM Duty';

View File

@ -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<List<Holiday>> 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<String, dynamic>>()
.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<Holiday>()
.toList();
}
}

View File

@ -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';

View File

@ -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');