Ensures allowing assigned IT Staff to check in outside geofence when and IT Service Request venue is outside premise
This commit is contained in:
@@ -100,8 +100,7 @@ class AttendanceController {
|
||||
'p_attendance_id': attendanceId,
|
||||
'p_lat': lat,
|
||||
'p_lng': lng,
|
||||
// ignore: use_null_aware_elements
|
||||
if (justification != null) 'p_justification': justification,
|
||||
'p_justification': justification,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import '../../providers/profile_provider.dart';
|
||||
import '../../providers/reports_provider.dart';
|
||||
import '../../providers/whereabouts_provider.dart';
|
||||
import '../../providers/workforce_provider.dart';
|
||||
import '../../providers/it_service_request_provider.dart';
|
||||
import '../../models/it_service_request.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/location_permission.dart';
|
||||
@@ -375,22 +377,22 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
}
|
||||
|
||||
final now = _currentTime;
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final todayStart = DateTime(now.year, now.month, now.day);
|
||||
final tomorrowStart = todayStart.add(const Duration(days: 1));
|
||||
final noon = DateTime(now.year, now.month, now.day, 12, 0);
|
||||
final onePM = DateTime(now.year, now.month, now.day, 13, 0);
|
||||
|
||||
// Find today's schedule for the current user.
|
||||
// Includes schedules where the user is a reliever (not only the primary user).
|
||||
// Exclude overtime schedules – they only belong in the Logbook.
|
||||
// We treat a schedule as "today" if it overlaps the local calendar day window.
|
||||
final schedules = schedulesAsync.valueOrNull ?? [];
|
||||
final todaySchedule = schedules.where((s) {
|
||||
final sDay = DateTime(
|
||||
s.startTime.year,
|
||||
s.startTime.month,
|
||||
s.startTime.day,
|
||||
);
|
||||
return s.userId == profile.id &&
|
||||
sDay == today &&
|
||||
s.shiftType != 'overtime';
|
||||
final isAssigned =
|
||||
s.userId == profile.id || s.relieverIds.contains(profile.id);
|
||||
final overlapsToday =
|
||||
s.startTime.isBefore(tomorrowStart) && s.endTime.isAfter(todayStart);
|
||||
return isAssigned && overlapsToday;
|
||||
}).toList();
|
||||
|
||||
// Find active attendance log (checked in but not out)
|
||||
@@ -402,14 +404,67 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
.where((l) => (l.justification ?? '').trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
// Roles allowed to render overtime anytime
|
||||
final allowedOvertimeRoles = {
|
||||
'admin',
|
||||
'programmer',
|
||||
'dispatcher',
|
||||
'it_staff',
|
||||
};
|
||||
final canRenderOvertime = allowedOvertimeRoles.contains(profile.role);
|
||||
// IT Service Request override (allows check-in/out outside geofence)
|
||||
final itRequests = ref.watch(itServiceRequestsProvider).valueOrNull ?? [];
|
||||
final itAssignments =
|
||||
ref.watch(itServiceRequestAssignmentsProvider).valueOrNull ?? [];
|
||||
final assignedRequestIds = itAssignments
|
||||
.where((a) => a.userId == profile.id)
|
||||
.map((a) => a.requestId)
|
||||
.toSet();
|
||||
final hasGeofenceOverride = itRequests.any((r) {
|
||||
return assignedRequestIds.contains(r.id) &&
|
||||
r.outsidePremiseAllowed &&
|
||||
(r.status == ItServiceRequestStatus.scheduled ||
|
||||
r.status == ItServiceRequestStatus.inProgressDryRun ||
|
||||
r.status == ItServiceRequestStatus.inProgress);
|
||||
});
|
||||
|
||||
final hasScheduleToday = todaySchedule.isNotEmpty;
|
||||
final latestScheduleEnd = hasScheduleToday
|
||||
? todaySchedule
|
||||
.map((s) => s.endTime)
|
||||
.reduce((a, b) => a.isAfter(b) ? a : b)
|
||||
: null;
|
||||
final isPastScheduleEnd =
|
||||
latestScheduleEnd != null && now.isAfter(latestScheduleEnd);
|
||||
|
||||
// If the user has an approved IT Service Request override, treat it as a "schedule" for
|
||||
// purposes of showing the normal check-in UI (even if the duty schedule list is empty).
|
||||
final hasEffectiveSchedule = hasScheduleToday || hasGeofenceOverride;
|
||||
|
||||
final showOvertimeCard =
|
||||
(activeOvertimeLog.isEmpty && _overtimeLogId == null) &&
|
||||
activeLog.isEmpty &&
|
||||
(!hasEffectiveSchedule || isPastScheduleEnd);
|
||||
|
||||
if (kDebugMode && showOvertimeCard) {
|
||||
final assignedSchedules = schedules
|
||||
.where(
|
||||
(s) => s.userId == profile.id || s.relieverIds.contains(profile.id),
|
||||
)
|
||||
.toList();
|
||||
final assignedTodaySchedules = todaySchedule;
|
||||
|
||||
debugPrint(
|
||||
'Attendance: showOvertimeCard=true (profile=${profile.id}, hasScheduleToday=$hasScheduleToday, isPastScheduleEnd=$isPastScheduleEnd, schedules=${schedules.length}, assigned=${assignedSchedules.length}, assignedToday=${assignedTodaySchedules.length})',
|
||||
);
|
||||
|
||||
if (assignedSchedules.isNotEmpty) {
|
||||
for (final s in assignedSchedules.take(6)) {
|
||||
debugPrint(
|
||||
' assigned: ${s.id} start=${s.startTime.toIso8601String()} end=${s.endTime.toIso8601String()} user=${s.userId} relievers=${s.relieverIds} shiftType=${s.shiftType}',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (assignedTodaySchedules.isNotEmpty) {
|
||||
for (final s in assignedTodaySchedules) {
|
||||
debugPrint(
|
||||
' assignedToday: ${s.id} start=${s.startTime.toIso8601String()} end=${s.endTime.toIso8601String()} user=${s.userId} relievers=${s.relieverIds} shiftType=${s.shiftType}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -608,11 +663,11 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
key: ValueKey(_insideGeofence),
|
||||
children: [
|
||||
Icon(
|
||||
_insideGeofence
|
||||
_insideGeofence || hasGeofenceOverride
|
||||
? Icons.check_circle
|
||||
: Icons.cancel,
|
||||
size: 16,
|
||||
color: _insideGeofence
|
||||
color: _insideGeofence || hasGeofenceOverride
|
||||
? Colors.green
|
||||
: colors.error,
|
||||
),
|
||||
@@ -620,15 +675,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
Text(
|
||||
_insideGeofence
|
||||
? 'Within geofence'
|
||||
: hasGeofenceOverride
|
||||
? 'Outside geofence (allowed by IT request)'
|
||||
: 'Outside geofence',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _insideGeofence
|
||||
color: _insideGeofence || hasGeofenceOverride
|
||||
? Colors.green
|
||||
: colors.error,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (!_insideGeofence) ...[
|
||||
if (!_insideGeofence && !hasGeofenceOverride) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@@ -658,13 +715,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
|
||||
if (activeOvertimeLog.isNotEmpty || _overtimeLogId != null)
|
||||
_buildActiveOvertimeCard(context, theme, colors, activeOvertimeLog)
|
||||
else if (_overtimeLogId == null &&
|
||||
(canRenderOvertime ||
|
||||
(todaySchedule.isEmpty && activeLog.isEmpty)))
|
||||
else if (showOvertimeCard)
|
||||
_buildOvertimeCard(context, theme, colors)
|
||||
else if (todaySchedule.isEmpty &&
|
||||
(activeLog.isNotEmpty || _overtimeLogId != null))
|
||||
_buildActiveOvertimeCard(context, theme, colors, activeLog)
|
||||
else
|
||||
...todaySchedule.map((schedule) {
|
||||
// All logs for this schedule.
|
||||
@@ -705,7 +757,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
final canCheckIn =
|
||||
!showCheckOut &&
|
||||
!isShiftOver &&
|
||||
_insideGeofence &&
|
||||
(_insideGeofence || hasGeofenceOverride) &&
|
||||
!_checkingGeofence;
|
||||
|
||||
return Card(
|
||||
@@ -807,6 +859,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
else if (!showCheckOut &&
|
||||
!isShiftOver &&
|
||||
!_insideGeofence &&
|
||||
!hasGeofenceOverride &&
|
||||
!_checkingGeofence)
|
||||
Center(
|
||||
child: SizedBox(
|
||||
@@ -912,10 +965,33 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
accuracy: LocationAccuracy.high,
|
||||
),
|
||||
);
|
||||
// Client-side geofence check (can be bypassed in debug mode)
|
||||
// Client-side geofence check (can be bypassed in debug mode or by an approved IT request)
|
||||
final debugBypass =
|
||||
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
|
||||
if (geoCfg != null && !debugBypass) {
|
||||
|
||||
// Allow outside check-in if the user has an approved IT Service Request
|
||||
// with outsidePremiseAllowed = true.
|
||||
final profile = ref.read(currentProfileProvider).valueOrNull;
|
||||
final hasItOverride = () {
|
||||
if (profile == null) return false;
|
||||
final itRequests =
|
||||
ref.watch(itServiceRequestsProvider).valueOrNull ?? [];
|
||||
final itAssignments =
|
||||
ref.watch(itServiceRequestAssignmentsProvider).valueOrNull ?? [];
|
||||
final assignedRequestIds = itAssignments
|
||||
.where((a) => a.userId == profile.id)
|
||||
.map((a) => a.requestId)
|
||||
.toSet();
|
||||
return itRequests.any((r) {
|
||||
return assignedRequestIds.contains(r.id) &&
|
||||
r.outsidePremiseAllowed &&
|
||||
(r.status == ItServiceRequestStatus.scheduled ||
|
||||
r.status == ItServiceRequestStatus.inProgressDryRun ||
|
||||
r.status == ItServiceRequestStatus.inProgress);
|
||||
});
|
||||
}();
|
||||
|
||||
if (geoCfg != null && !debugBypass && !hasItOverride) {
|
||||
bool inside = false;
|
||||
if (geoCfg.hasPolygon) {
|
||||
inside = geoCfg.containsPolygon(
|
||||
@@ -935,8 +1011,13 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
showWarningSnackBar(context, 'You are outside the geofence area.');
|
||||
return;
|
||||
}
|
||||
} else if (debugBypass && mounted) {
|
||||
showInfoSnackBar(context, '⚠️ DEBUG: Geofence check bypassed');
|
||||
} else if ((debugBypass || hasItOverride) && mounted) {
|
||||
showInfoSnackBar(
|
||||
context,
|
||||
hasItOverride
|
||||
? 'Allowed by approved IT Service Request.'
|
||||
: '⚠️ DEBUG: Geofence check bypassed',
|
||||
);
|
||||
}
|
||||
final logId = await ref
|
||||
.read(attendanceControllerProvider)
|
||||
|
||||
Reference in New Issue
Block a user