Added configurable shift types and holiday settings

This commit is contained in:
2026-03-18 16:51:34 +08:00
parent 4eaf9444f0
commit 3af7a1e348
7 changed files with 1463 additions and 138 deletions
+387 -117
View File
@@ -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<String, Profile> 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<bool>(
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<String>(
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<String>? relieverIds,
}) : relieverIds = relieverIds ?? <String>[];
@@ -793,6 +919,7 @@ class _DraftSchedule {
DateTime startTime;
DateTime endTime;
List<String> 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<String, _ShiftTemplate> _buildTemplates(List<DutySchedule> schedules) {
Map<String, _ShiftTemplate> _buildTemplates(
List<DutySchedule> schedules,
List<ShiftTypeConfig> shiftTypes,
) {
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) {
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<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 (
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<String> 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 = <String>[];
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<String, DutySchedule> 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';