Workforce rotation settings and location permission handling

This commit is contained in:
2026-03-08 17:31:39 +08:00
parent ce82a88e04
commit 8bf0dc13d7
8 changed files with 1200 additions and 26 deletions
+112 -23
View File
@@ -6,15 +6,18 @@ import 'package:timezone/timezone.dart' as tz;
import '../../models/duty_schedule.dart';
import '../../models/profile.dart';
import '../../models/rotation_config.dart';
import '../../models/swap_request.dart';
import '../../providers/profile_provider.dart';
import '../../providers/rotation_config_provider.dart';
import '../../providers/workforce_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/ramadan_provider.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart';
import 'rotation_settings_dialog.dart';
class WorkforceScreen extends ConsumerWidget {
const WorkforceScreen({super.key});
@@ -879,6 +882,12 @@ class _ScheduleGeneratorPanelState
),
),
],
const Spacer(),
IconButton(
tooltip: 'Rotation settings',
icon: const Icon(Icons.settings_outlined),
onPressed: () => showRotationSettings(context, ref),
),
],
),
const SizedBox(height: 12),
@@ -1009,6 +1018,7 @@ class _ScheduleGeneratorPanelState
return;
}
final rotationConfig = ref.read(rotationConfigProvider).valueOrNull;
final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? [];
final templates = _buildTemplates(schedules);
final generated = _generateDrafts(
@@ -1017,6 +1027,7 @@ class _ScheduleGeneratorPanelState
staff,
schedules,
templates,
rotationConfig: rotationConfig,
);
generated.sort((a, b) => a.startTime.compareTo(b.startTime));
final warnings = _buildWarnings(start, end, generated);
@@ -1544,18 +1555,54 @@ class _ScheduleGeneratorPanelState
DateTime end,
List<Profile> staff,
List<DutySchedule> schedules,
Map<String, _ShiftTemplate> templates,
) {
Map<String, _ShiftTemplate> templates, {
RotationConfig? rotationConfig,
}) {
final draft = <_DraftSchedule>[];
final normalizedStart = DateTime(start.year, start.month, start.day);
final normalizedEnd = DateTime(end.year, end.month, end.day);
final existing = schedules;
// Only IT Staff rotate through AM/PM/on_call shifts
final itStaff = staff.where((p) => p.role == 'it_staff').toList();
final excludedIds = rotationConfig?.excludedStaffIds ?? [];
// Only IT Staff rotate through AM/PM/on_call shifts (minus excluded)
final allItStaff = staff.where((p) => p.role == 'it_staff').toList();
final itStaff = allItStaff
.where((p) => !excludedIds.contains(p.id))
.toList();
// Sort IT staff by configured rotation order if available
if (rotationConfig != null && rotationConfig.rotationOrder.isNotEmpty) {
final order = rotationConfig.rotationOrder;
itStaff.sort((a, b) {
final ai = order.indexOf(a.id);
final bi = order.indexOf(b.id);
final aIdx = ai == -1 ? order.length : ai;
final bIdx = bi == -1 ? order.length : bi;
return aIdx.compareTo(bIdx);
});
}
// Non-Islam IT staff for Friday AM rotation
final fridayAmStaff = itStaff.where((p) => p.religion != 'islam').toList();
if (rotationConfig != null && rotationConfig.fridayAmOrder.isNotEmpty) {
final order = rotationConfig.fridayAmOrder;
fridayAmStaff.sort((a, b) {
final ai = order.indexOf(a.id);
final bi = order.indexOf(b.id);
final aIdx = ai == -1 ? order.length : ai;
final bIdx = bi == -1 ? order.length : bi;
return aIdx.compareTo(bIdx);
});
}
// Admin/Dispatcher always get normal shift (no rotation)
final nonRotating = staff.where((p) => p.role != 'it_staff').toList();
// Track Friday AM rotation index separately
var fridayAmRotationIdx = 0;
var isFirstWeek = true;
var weekStart = _startOfWeek(normalizedStart);
while (!weekStart.isAfter(normalizedEnd)) {
@@ -1592,18 +1639,45 @@ class _ScheduleGeneratorPanelState
];
// Rotation indices only for IT Staff
final amBaseIndex = _nextIndexFromLastWeek(
shiftType: 'am',
staff: itStaff,
lastWeek: lastWeek,
defaultIndex: 0,
);
final pmBaseIndex = _nextIndexFromLastWeek(
shiftType: 'pm',
staff: itStaff,
lastWeek: lastWeek,
defaultIndex: itStaff.length > 1 ? 1 : 0,
);
int amBaseIndex;
int pmBaseIndex;
if (isFirstWeek && rotationConfig?.initialAmStaffId != null) {
// Use configured initial AM
final idx = itStaff.indexWhere(
(p) => p.id == rotationConfig!.initialAmStaffId,
);
amBaseIndex = idx != -1 ? idx : 0;
} else {
amBaseIndex = _nextIndexFromLastWeek(
shiftType: 'am',
staff: itStaff,
lastWeek: lastWeek,
defaultIndex: 0,
);
}
if (isFirstWeek && rotationConfig?.initialPmStaffId != null) {
// Use configured initial PM
final idx = itStaff.indexWhere(
(p) => p.id == rotationConfig!.initialPmStaffId,
);
pmBaseIndex = idx != -1 ? idx : (itStaff.length > 1 ? 2 : 0);
} else {
// PM duty is 2 positions after AM in the rotation to avoid conflicts
final defaultPmIndex = itStaff.length > 2
? (amBaseIndex + 2) % itStaff.length
: (itStaff.length > 1 ? (amBaseIndex + 1) % itStaff.length : 0);
pmBaseIndex = _nextIndexFromLastWeek(
shiftType: 'pm',
staff: itStaff,
lastWeek: lastWeek,
defaultIndex: defaultPmIndex,
);
}
isFirstWeek = false;
final amUserId = itStaff.isEmpty
? null
: itStaff[amBaseIndex % itStaff.length].id;
@@ -1664,16 +1738,31 @@ class _ScheduleGeneratorPanelState
final isFriday = day.weekday == DateTime.friday;
final profileMap = _profileById();
final amProfile = amUserId != null ? profileMap[amUserId] : null;
final skipAmForRamadan =
dayIsRamadan && isFriday && amProfile?.religion == 'islam';
if (amUserId != null && !skipAmForRamadan) {
// Friday AM: use separate non-Islam rotation
String? effectiveAmUserId = amUserId;
if (isFriday && (dayIsRamadan || true)) {
// On Fridays, only non-Muslim IT Staff can take AM
if (amProfile?.religion == 'islam') {
// Use Friday AM rotation list
if (fridayAmStaff.isNotEmpty) {
effectiveAmUserId =
fridayAmStaff[fridayAmRotationIdx % fridayAmStaff.length]
.id;
fridayAmRotationIdx++;
} else {
effectiveAmUserId = null; // No eligible staff
}
}
}
if (effectiveAmUserId != null) {
_tryAddDraft(
draft,
existing,
templates,
'am',
amUserId,
effectiveAmUserId,
day,
const [],
);
@@ -1699,12 +1788,12 @@ class _ScheduleGeneratorPanelState
);
}
// Remaining IT Staff get normal shift
// Remaining IT Staff (including excluded) get normal shift
final assignedToday = <String?>[
amUserId,
effectiveAmUserId,
pmUserId,
].whereType<String>().toSet();
for (final profile in itStaff) {
for (final profile in allItStaff) {
if (assignedToday.contains(profile.id)) continue;
final normalKey = dayIsRamadan && profile.religion == 'islam'
? 'normal_ramadan_islam'