diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index 8e12f37e..5ba43893 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -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, }, ); } diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 7b914a0e..eb335344 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -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) diff --git a/supabase/migrations/20260316120000_it_service_request_geofence_override.sql b/supabase/migrations/20260316120000_it_service_request_geofence_override.sql new file mode 100644 index 00000000..7fbfecc4 --- /dev/null +++ b/supabase/migrations/20260316120000_it_service_request_geofence_override.sql @@ -0,0 +1,193 @@ +-- Allow assigned IT staff to check in/out outside the geofence when an approved +-- IT Service Request has "outside_premise_allowed" enabled. +-- +-- This enforces the same behavior server-side so clients cannot bypass geofence +-- checks by sending lat/lng values directly. + +-- Recreate attendance_check_in to validate geofence and allow override. +CREATE OR REPLACE FUNCTION public.attendance_check_in( + p_duty_id uuid, + p_lat double precision, + p_lng double precision +) RETURNS uuid +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_schedule duty_schedules%ROWTYPE; + v_log_id uuid; + v_now timestamptz := now(); + v_status text; + v_geofence jsonb; + v_in_premise boolean := true; + v_polygon jsonb; + v_point_count int; + v_i int; + v_j int; + v_xi double precision; + v_yi double precision; + v_xj double precision; + v_yj double precision; + v_inside boolean := false; + v_override boolean := false; +BEGIN + SELECT * INTO v_schedule FROM duty_schedules WHERE id = p_duty_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'Duty schedule not found'; + END IF; + IF v_schedule.user_id != auth.uid() THEN + RAISE EXCEPTION 'Not your duty schedule'; + END IF; + + -- Validate geofence (unless overridden by a signed IT service request) + SELECT value INTO v_geofence FROM app_settings WHERE key = 'geofence'; + IF v_geofence IS NOT NULL THEN + v_polygon := COALESCE(v_geofence->'polygon', v_geofence->'points'); + IF v_polygon IS NOT NULL AND jsonb_array_length(v_polygon) > 2 THEN + -- Ray-casting point-in-polygon + v_point_count := jsonb_array_length(v_polygon); + v_j := v_point_count - 1; + FOR v_i IN 0..(v_point_count - 1) LOOP + v_xi := (v_polygon->v_i->>'lng')::double precision; + v_yi := (v_polygon->v_i->>'lat')::double precision; + v_xj := (v_polygon->v_j->>'lng')::double precision; + v_yj := (v_polygon->v_j->>'lat')::double precision; + + IF ((v_yi > p_lat) != (v_yj > p_lat)) AND + (p_lng < (v_xj - v_xi) * (p_lat - v_yi) / (v_yj - v_yi) + v_xi) THEN + v_inside := NOT v_inside; + END IF; + v_j := v_i; + END LOOP; + v_in_premise := v_inside; + END IF; + END IF; + + IF NOT v_in_premise THEN + SELECT EXISTS ( + SELECT 1 + FROM it_service_request_assignments a + JOIN it_service_requests r ON r.id = a.request_id + WHERE a.user_id = auth.uid() + AND r.outside_premise_allowed + AND r.status IN ('scheduled', 'in_progress_dry_run', 'in_progress') + ) INTO v_override; + + IF NOT v_override THEN + RAISE EXCEPTION 'Outside geofence'; + END IF; + END IF; + + -- Check 2-hour window + IF v_now < (v_schedule.start_time - interval '2 hours') THEN + RAISE EXCEPTION 'Too early to check in (2-hour window)'; + END IF; + IF v_now > v_schedule.end_time THEN + RAISE EXCEPTION 'Duty has already ended'; + END IF; + + -- Determine status (used only when first check-in) + IF v_now <= v_schedule.start_time THEN + v_status := 'arrival'; + ELSE + v_status := 'late'; + END IF; + + -- Insert a new attendance log row for each check-in + INSERT INTO attendance_logs (user_id, duty_schedule_id, check_in_at, check_in_lat, check_in_lng) + VALUES (auth.uid(), p_duty_id, v_now, p_lat, p_lng) + RETURNING id INTO v_log_id; + + -- Only update duty schedule on first check-in (preserve original arrival status) + UPDATE duty_schedules + SET check_in_at = COALESCE(check_in_at, v_now), + check_in_location = ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography, + status = CASE + WHEN check_in_at IS NULL THEN v_status::duty_status + ELSE status + END + WHERE id = p_duty_id; + + RETURN v_log_id; +END; +$$; + +-- Recreate attendance_check_out to validate geofence and allow override. +CREATE OR REPLACE FUNCTION public.attendance_check_out( + p_attendance_id uuid, + p_lat double precision, + p_lng double precision +) RETURNS void +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_log attendance_logs%ROWTYPE; + v_geofence jsonb; + v_in_premise boolean := true; + v_polygon jsonb; + v_point_count int; + v_i int; + v_j int; + v_xi double precision; + v_yi double precision; + v_xj double precision; + v_yj double precision; + v_inside boolean := false; + v_override boolean := false; +BEGIN + SELECT * INTO v_log FROM attendance_logs WHERE id = p_attendance_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'Attendance log not found'; + END IF; + IF v_log.user_id != auth.uid() THEN + RAISE EXCEPTION 'Not your attendance log'; + END IF; + IF v_log.check_out_at IS NOT NULL THEN + RAISE EXCEPTION 'Already checked out'; + END IF; + + -- Validate geofence (unless overridden by a signed IT service request) + SELECT value INTO v_geofence FROM app_settings WHERE key = 'geofence'; + IF v_geofence IS NOT NULL THEN + v_polygon := COALESCE(v_geofence->'polygon', v_geofence->'points'); + IF v_polygon IS NOT NULL AND jsonb_array_length(v_polygon) > 2 THEN + -- Ray-casting point-in-polygon + v_point_count := jsonb_array_length(v_polygon); + v_j := v_point_count - 1; + FOR v_i IN 0..(v_point_count - 1) LOOP + v_xi := (v_polygon->v_i->>'lng')::double precision; + v_yi := (v_polygon->v_i->>'lat')::double precision; + v_xj := (v_polygon->v_j->>'lng')::double precision; + v_yj := (v_polygon->v_j->>'lat')::double precision; + + IF ((v_yi > p_lat) != (v_yj > p_lat)) AND + (p_lng < (v_xj - v_xi) * (p_lat - v_yi) / (v_yj - v_yi) + v_xi) THEN + v_inside := NOT v_inside; + END IF; + v_j := v_i; + END LOOP; + v_in_premise := v_inside; + END IF; + END IF; + + IF NOT v_in_premise THEN + SELECT EXISTS ( + SELECT 1 + FROM it_service_request_assignments a + JOIN it_service_requests r ON r.id = a.request_id + WHERE a.user_id = auth.uid() + AND r.outside_premise_allowed + AND r.status IN ('scheduled', 'in_progress_dry_run', 'in_progress') + ) INTO v_override; + + IF NOT v_override THEN + RAISE EXCEPTION 'Outside geofence'; + END IF; + END IF; + + UPDATE attendance_logs + SET check_out_at = now(), + check_out_lat = p_lat, + check_out_lng = p_lng + WHERE id = p_attendance_id; +END; +$$; diff --git a/supabase/migrations/20260317100000_remove_geofence_validation_from_attendance.sql b/supabase/migrations/20260317100000_remove_geofence_validation_from_attendance.sql new file mode 100644 index 00000000..734e49fd --- /dev/null +++ b/supabase/migrations/20260317100000_remove_geofence_validation_from_attendance.sql @@ -0,0 +1,92 @@ +-- Revert server-side geofence validation in attendance_check_in/check_out. +-- +-- This migration restores the original behavior where the client is responsible +-- for geofence enforcement (and where check-in/check-out doesn't validate +-- whether the provided location is inside the geofence). + +-- Recreate attendance_check_in without geofence validation. +CREATE OR REPLACE FUNCTION public.attendance_check_in( + p_duty_id uuid, + p_lat double precision, + p_lng double precision +) RETURNS uuid +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_schedule duty_schedules%ROWTYPE; + v_log_id uuid; + v_now timestamptz := now(); + v_status text; +BEGIN + SELECT * INTO v_schedule FROM duty_schedules WHERE id = p_duty_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'Duty schedule not found'; + END IF; + IF v_schedule.user_id != auth.uid() THEN + RAISE EXCEPTION 'Not your duty schedule'; + END IF; + + IF v_now < (v_schedule.start_time - interval '2 hours') THEN + RAISE EXCEPTION 'Too early to check in (2-hour window)'; + END IF; + IF v_now > v_schedule.end_time THEN + RAISE EXCEPTION 'Duty has already ended'; + END IF; + + -- Determine status (used only when first check-in) + IF v_now <= v_schedule.start_time THEN + v_status := 'arrival'; + ELSE + v_status := 'late'; + END IF; + + -- Insert a new attendance log row for each check-in + INSERT INTO attendance_logs (user_id, duty_schedule_id, check_in_at, check_in_lat, check_in_lng) + VALUES (auth.uid(), p_duty_id, v_now, p_lat, p_lng) + RETURNING id INTO v_log_id; + + -- Only update duty schedule on first check-in (preserve original arrival status) + UPDATE duty_schedules + SET check_in_at = COALESCE(check_in_at, v_now), + check_in_location = ST_SetSRID(ST_MakePoint(p_lng, p_lat), 4326)::geography, + status = CASE + WHEN check_in_at IS NULL THEN v_status::duty_status + ELSE status + END + WHERE id = p_duty_id; + + RETURN v_log_id; +END; +$$; + +-- Recreate attendance_check_out without geofence validation. +CREATE OR REPLACE FUNCTION public.attendance_check_out( + p_attendance_id uuid, + p_lat double precision, + p_lng double precision, + p_justification text DEFAULT NULL +) RETURNS void +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_log attendance_logs%ROWTYPE; +BEGIN + SELECT * INTO v_log FROM attendance_logs WHERE id = p_attendance_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'Attendance log not found'; + END IF; + IF v_log.user_id != auth.uid() THEN + RAISE EXCEPTION 'Not your attendance log'; + END IF; + IF v_log.check_out_at IS NOT NULL THEN + RAISE EXCEPTION 'Already checked out'; + END IF; + + UPDATE attendance_logs + SET check_out_at = now(), + check_out_lat = p_lat, + check_out_lng = p_lng, + justification = COALESCE(p_justification, justification) + WHERE id = p_attendance_id; +END; +$$; diff --git a/supabase/migrations/20260317110000_fix_attendance_checkout_overload.sql b/supabase/migrations/20260317110000_fix_attendance_checkout_overload.sql new file mode 100644 index 00000000..ef2fc960 --- /dev/null +++ b/supabase/migrations/20260317110000_fix_attendance_checkout_overload.sql @@ -0,0 +1,39 @@ +-- Ensure there is only one overload of attendance_check_out. +-- +-- Having both the 3-parameter and 4-parameter overloads causes PostgREST to +-- return "could not choose best candidate function" when calling the RPC with +-- only the required parameters. + +DROP FUNCTION IF EXISTS public.attendance_check_out(uuid, double precision, double precision); + +-- Ensure the 4-parameter version (with optional justification) exists. +CREATE OR REPLACE FUNCTION public.attendance_check_out( + p_attendance_id uuid, + p_lat double precision, + p_lng double precision, + p_justification text DEFAULT NULL +) RETURNS void +LANGUAGE plpgsql SECURITY DEFINER +AS $$ +DECLARE + v_log attendance_logs%ROWTYPE; +BEGIN + SELECT * INTO v_log FROM attendance_logs WHERE id = p_attendance_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'Attendance log not found'; + END IF; + IF v_log.user_id != auth.uid() THEN + RAISE EXCEPTION 'Not your attendance log'; + END IF; + IF v_log.check_out_at IS NOT NULL THEN + RAISE EXCEPTION 'Already checked out'; + END IF; + + UPDATE attendance_logs + SET check_out_at = now(), + check_out_lat = p_lat, + check_out_lng = p_lng, + check_out_justification = p_justification + WHERE id = p_attendance_id; +END; +$$;