From 049ab2c794416977a6fb7ccdf2a3e3efb6f92aa1 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 22 Mar 2026 11:52:25 +0800 Subject: [PATCH] Added My Schedule tab in attendance screen Allow 1 Day, Whole Week and Date Range swapping --- lib/models/duty_schedule.dart | 3 + lib/models/pass_slip.dart | 5 + lib/providers/pass_slip_provider.dart | 23 +- lib/providers/stream_recovery.dart | 5 + lib/providers/workforce_provider.dart | 48 +- lib/routing/app_router.dart | 32 +- lib/screens/attendance/attendance_screen.dart | 1728 ++++++++++++++++- lib/screens/workforce/workforce_screen.dart | 274 +-- lib/utils/app_time.dart | 15 + lib/widgets/pass_slip_countdown_banner.dart | 31 +- .../process_scheduled_notifications/index.ts | 5 + ...000_pass_slip_start_and_expired_notifs.sql | 78 + ..._add_swap_request_id_to_duty_schedules.sql | 99 + .../20260322170000_fix_respond_shift_swap.sql | 117 ++ ...60322180000_auto_reject_orphaned_swaps.sql | 120 ++ ...0260322190000_pm_oncall_companion_swap.sql | 227 +++ ...20260322200000_duty_schedules_realtime.sql | 10 + 17 files changed, 2515 insertions(+), 305 deletions(-) create mode 100644 supabase/migrations/20260322150000_pass_slip_start_and_expired_notifs.sql create mode 100644 supabase/migrations/20260322160000_add_swap_request_id_to_duty_schedules.sql create mode 100644 supabase/migrations/20260322170000_fix_respond_shift_swap.sql create mode 100644 supabase/migrations/20260322180000_auto_reject_orphaned_swaps.sql create mode 100644 supabase/migrations/20260322190000_pm_oncall_companion_swap.sql create mode 100644 supabase/migrations/20260322200000_duty_schedules_realtime.sql diff --git a/lib/models/duty_schedule.dart b/lib/models/duty_schedule.dart index ae549f1e..5657e460 100644 --- a/lib/models/duty_schedule.dart +++ b/lib/models/duty_schedule.dart @@ -12,6 +12,7 @@ class DutySchedule { required this.checkInAt, required this.checkInLocation, required this.relieverIds, + this.swapRequestId, }); final String id; @@ -24,6 +25,7 @@ class DutySchedule { final DateTime? checkInAt; final Object? checkInLocation; final List relieverIds; + final String? swapRequestId; factory DutySchedule.fromMap(Map map) { final relieversRaw = map['reliever_ids']; @@ -47,6 +49,7 @@ class DutySchedule { : AppTime.parse(map['check_in_at'] as String), checkInLocation: map['check_in_location'], relieverIds: relievers, + swapRequestId: map['swap_request_id'] as String?, ); } } diff --git a/lib/models/pass_slip.dart b/lib/models/pass_slip.dart index 98abfffb..eaa13893 100644 --- a/lib/models/pass_slip.dart +++ b/lib/models/pass_slip.dart @@ -12,6 +12,7 @@ class PassSlip { this.approvedAt, this.slipStart, this.slipEnd, + this.requestedStart, }); final String id; @@ -24,6 +25,7 @@ class PassSlip { final DateTime? approvedAt; final DateTime? slipStart; final DateTime? slipEnd; + final DateTime? requestedStart; /// Whether the slip is active (approved but not yet completed). bool get isActive => status == 'approved' && slipEnd == null; @@ -52,6 +54,9 @@ class PassSlip { slipEnd: map['slip_end'] == null ? null : AppTime.parse(map['slip_end'] as String), + requestedStart: map['requested_start'] == null + ? null + : AppTime.parse(map['requested_start'] as String), ); } } diff --git a/lib/providers/pass_slip_provider.dart b/lib/providers/pass_slip_provider.dart index 962cb495..03a1c6c1 100644 --- a/lib/providers/pass_slip_provider.dart +++ b/lib/providers/pass_slip_provider.dart @@ -78,6 +78,7 @@ class PassSlipController { Future requestSlip({ required String dutyScheduleId, required String reason, + DateTime? requestedStart, }) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); @@ -87,6 +88,8 @@ class PassSlipController { 'reason': reason, 'status': 'pending', 'requested_at': DateTime.now().toUtc().toIso8601String(), + if (requestedStart != null) + 'requested_start': requestedStart.toUtc().toIso8601String(), }; final insertedRaw = await _client @@ -174,13 +177,29 @@ class PassSlipController { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); + // Determine slip start time based on requested_start + final nowUtc = DateTime.now().toUtc(); + String slipStartIso = nowUtc.toIso8601String(); + + final row = await _client + .from('pass_slips') + .select('requested_start') + .eq('id', slipId) + .maybeSingle(); + if (row != null && row['requested_start'] != null) { + final requestedStart = DateTime.parse(row['requested_start'] as String); + if (requestedStart.isAfter(nowUtc)) { + slipStartIso = requestedStart.toIso8601String(); + } + } + await _client .from('pass_slips') .update({ 'status': 'approved', 'approved_by': userId, - 'approved_at': DateTime.now().toUtc().toIso8601String(), - 'slip_start': DateTime.now().toUtc().toIso8601String(), + 'approved_at': nowUtc.toIso8601String(), + 'slip_start': slipStartIso, }) .eq('id', slipId); diff --git a/lib/providers/stream_recovery.dart b/lib/providers/stream_recovery.dart index c35b0d21..046df943 100644 --- a/lib/providers/stream_recovery.dart +++ b/lib/providers/stream_recovery.dart @@ -344,6 +344,11 @@ class StreamRecoveryWrapper { } } + /// Immediately fetch fresh data via REST without restarting the realtime + /// subscription. Use this as a periodic safety net for missed realtime events + /// (e.g., when the table is not yet in the supabase_realtime publication). + Future pollNow() async => _pollOnce(); + /// Manually trigger a recovery attempt. void retry() { _recoveryAttempts = 0; diff --git a/lib/providers/workforce_provider.dart b/lib/providers/workforce_provider.dart index 94990a32..b2b4d165 100644 --- a/lib/providers/workforce_provider.dart +++ b/lib/providers/workforce_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -53,6 +55,20 @@ final dutySchedulesProvider = StreamProvider>((ref) { ); ref.onDispose(wrapper.dispose); + + // Immediate poll so any changes that happened while this provider was + // not alive (e.g. a swap was accepted on another device) are reflected + // right away — before the 3-second periodic timer fires. + wrapper.pollNow(); + + // Periodic safety-net: keep polling every 3 s so that ownership changes + // (swap accepted → user_id updated on duty_schedules) are always picked + // up even if Supabase Realtime misses the event. + final dutyRefreshTimer = Timer.periodic(const Duration(seconds: 3), (_) { + wrapper.pollNow(); + }); + ref.onDispose(dutyRefreshTimer.cancel); + return wrapper.stream.map((result) => result.data); }); @@ -127,6 +143,7 @@ final swapRequestsProvider = StreamProvider>((ref) { final data = await client .from('swap_requests') .select() + .inFilter('status', ['pending', 'admin_review']) .order('created_at', ascending: false); return data.map(SwapRequest.fromMap).toList(); }, @@ -136,13 +153,28 @@ final swapRequestsProvider = StreamProvider>((ref) { ); ref.onDispose(wrapper.dispose); + + // Immediate poll: fetch fresh data right away so any status changes that + // happened while this provider was not alive are reflected instantly, + // before the 3-second periodic timer fires for the first time. + wrapper.pollNow(); + + // Periodic safety-net: keep polling every 3 s to catch any status changes + // that Supabase Realtime may have missed (e.g. when the swap_requests table + // is not yet in the supabase_realtime publication). + final refreshTimer = Timer.periodic(const Duration(seconds: 3), (_) { + wrapper.pollNow(); + }); + ref.onDispose(refreshTimer.cancel); + return wrapper.stream.map((result) { // only return requests that are still actionable; once a swap has been // accepted or rejected we no longer need to bubble it up to the UI for // either party. admins still see "admin_review" rows so they can act on // escalated cases. return result.data.where((row) { - if (!(row.requesterId == profileId || row.recipientId == profileId)) { + // admins see all swaps; standard users only see swaps they're in + if (!isAdmin && !(row.requesterId == profileId || row.recipientId == profileId)) { return false; } // only keep pending and admin_review statuses @@ -151,6 +183,20 @@ final swapRequestsProvider = StreamProvider>((ref) { }); }); +/// IDs of swap requests that were acted on locally (accepted, rejected, etc.). +/// Kept as a global provider so the set survives tab switches — widget state +/// is disposed when the user navigates away from My Schedule. +final locallyRemovedSwapIdsProvider = StateProvider>((ref) => {}); + +/// IDs of duty_schedules owned by the current user that were created by an accepted swap. +final swappedScheduleIdsProvider = Provider>((ref) { + final schedules = ref.watch(dutySchedulesProvider).valueOrNull ?? []; + return { + for (final s in schedules) + if (s.swapRequestId != null) s.id, + }; +}); + final workforceControllerProvider = Provider((ref) { final client = ref.watch(supabaseClientProvider); return WorkforceController(client); diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index f4eb8e94..debd59b6 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -34,6 +34,17 @@ import '../theme/m3_motion.dart'; import '../utils/navigation.dart'; +String _defaultRouteForRole(String? role) { + switch (role) { + case 'it_staff': + return '/tasks'; + case 'standard': + return '/tickets'; + default: + return '/dashboard'; + } +} + final appRouterProvider = Provider((ref) { final notifier = RouterNotifier(ref); ref.onDispose(notifier.dispose); @@ -71,7 +82,19 @@ final appRouterProvider = Provider((ref) { return '/login'; } if (isSignedIn && isAuthRoute) { - return '/dashboard'; + // If role already loaded, redirect directly and clear any pending flag + if (role != null) notifier._needsRoleRedirect = false; + return _defaultRouteForRole(role); + } + // Deferred post-login redirect: profile loaded after the initial redirect + // (which fell back to /dashboard because role was null). Only fires once + // per sign-in and only when the user is still on /dashboard. + if (isSignedIn && + notifier._needsRoleRedirect && + role != null && + state.matchedLocation == '/dashboard') { + notifier._needsRoleRedirect = false; + return _defaultRouteForRole(role); } if (isAdminRoute && !isAdmin) { return '/tickets'; @@ -274,8 +297,9 @@ class RouterNotifier extends ChangeNotifier { ? previous.value?.session : null; if (session != null && previousSession == null) { - // User just signed in; enforce lock check + // User just signed in; enforce lock check and flag for role-based redirect _enforceLockAsync(); + _needsRoleRedirect = true; } } notifyListeners(); @@ -290,6 +314,10 @@ class RouterNotifier extends ChangeNotifier { late final ProviderSubscription _profileSub; bool _lockEnforcementInProgress = false; + /// Set on new sign-in. Consumed by the redirect function to send the user + /// to their role-appropriate landing page once the profile has loaded. + bool _needsRoleRedirect = false; + /// Safely enforce lock in the background, preventing concurrent calls void _enforceLockAsync() { // Prevent concurrent enforcement calls diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index b7266195..f3c36a34 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -13,6 +13,7 @@ import '../../models/duty_schedule.dart'; import '../../models/leave_of_absence.dart'; import '../../models/pass_slip.dart'; import '../../models/profile.dart'; +import '../../models/rotation_config.dart'; import '../../providers/attendance_provider.dart'; import '../../providers/debug_settings_provider.dart'; import '../../providers/leave_provider.dart'; @@ -20,15 +21,20 @@ import '../../screens/dashboard/dashboard_screen.dart'; import '../../providers/pass_slip_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/reports_provider.dart'; +import '../../providers/rotation_config_provider.dart'; import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; +import '../../providers/notifications_provider.dart'; import '../../providers/it_service_request_provider.dart'; import '../../models/it_service_request.dart'; +import '../../models/swap_request.dart'; import '../../theme/m3_motion.dart'; +import '../../theme/app_surfaces.dart'; import '../../utils/app_time.dart'; import '../../utils/location_permission.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../widgets/face_verification_overlay.dart'; +import '../../widgets/app_state_view.dart'; import '../../utils/snackbar.dart'; import '../../widgets/gemini_animated_text_field.dart'; import '../../widgets/gemini_button.dart'; @@ -55,7 +61,7 @@ class _AttendanceScreenState extends ConsumerState @override void initState() { super.initState(); - _tabController = TabController(length: 4, vsync: this); + _tabController = TabController(length: 5, vsync: this); _tabController.addListener(_onTabChanged); } @@ -76,7 +82,7 @@ class _AttendanceScreenState extends ConsumerState final theme = Theme.of(context); final colors = theme.colorScheme; final profile = ref.watch(currentProfileProvider).valueOrNull; - final showFab = _tabController.index >= 2; // Pass Slip or Leave tabs + final showFab = _tabController.index >= 3; // Pass Slip or Leave tabs return ResponsiveBody( maxWidth: 1200, @@ -98,6 +104,7 @@ class _AttendanceScreenState extends ConsumerState tabs: const [ Tab(text: 'Check In'), Tab(text: 'Logbook'), + Tab(text: 'My Schedule'), Tab(text: 'Pass Slip'), Tab(text: 'Leave'), ], @@ -105,11 +112,12 @@ class _AttendanceScreenState extends ConsumerState Expanded( child: TabBarView( controller: _tabController, - children: const [ - _CheckInTab(), - _LogbookTab(), - _PassSlipTab(), - _LeaveTab(), + children: [ + const _CheckInTab(), + const _LogbookTab(), + _MyScheduleTab(), + const _PassSlipTab(), + const _LeaveTab(), ], ), ), @@ -3443,7 +3451,1404 @@ class _Preset { } // ──────────────────────────────────────────────── -// Tab 3 – Pass Slip +// Tab 3 – My Schedule +// ──────────────────────────────────────────────── + +class _MyScheduleTab extends ConsumerStatefulWidget { + const _MyScheduleTab(); + + @override + ConsumerState<_MyScheduleTab> createState() => _MyScheduleTabState(); +} + +class _MyScheduleTabState extends ConsumerState<_MyScheduleTab> { + // Tracks which swap-group cards are expanded (keyed by requesterId_recipientId). + final Set _expandedGroupKeys = {}; + + @override + Widget build(BuildContext context) { + final schedulesAsync = ref.watch(dutySchedulesProvider); + final swapsAsync = ref.watch(swapRequestsProvider); + final removedSwapIds = ref.watch(locallyRemovedSwapIdsProvider); + final currentUserId = ref.watch(currentUserIdProvider); + final profilesAsync = ref.watch(profilesProvider); + final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; + final userProfile = ref.watch(currentProfileProvider).valueOrNull; + final isItStaff = userProfile?.role == 'it_staff'; + final colors = Theme.of(context).colorScheme; + + return Column( + children: [ + Expanded( + child: schedulesAsync.when( + data: (allSchedules) { + final now = AppTime.now(); + final today = DateTime(now.year, now.month, now.day); + + // Filter to current user's upcoming non-overtime schedules + final mySchedules = allSchedules + .where((s) => + s.userId == currentUserId && + s.shiftType != 'overtime' && + !s.endTime.isBefore(today)) + .toList() + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + + // Get pending swaps for current user, excluding any locally + // acted-on IDs so the card disappears immediately without + // waiting for the stream to re-emit. + final pendingSwaps = (swapsAsync.valueOrNull ?? []) + .where((s) => + !removedSwapIds.contains(s.id) && + (s.requesterId == currentUserId || + s.recipientId == currentUserId) && + (s.status == 'pending' || s.status == 'admin_review')) + .toList(); + + final Map profileById = { + for (final profile in profilesAsync.valueOrNull ?? []) + profile.id: profile, + }; + + if (mySchedules.isEmpty && pendingSwaps.isEmpty) { + return const Center( + child: Text('No upcoming schedules or swap requests.'), + ); + } + + return CustomScrollView( + slivers: [ + if (mySchedules.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: + const EdgeInsets.only(left: 16, top: 16, bottom: 8), + child: Text( + 'My Shifts', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ), + SliverList.separated( + itemCount: mySchedules.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final schedule = mySchedules[index]; + final hasPendingSwap = pendingSwaps.any((s) => + s.requesterScheduleId == schedule.id && + s.requesterId == currentUserId && + s.status == 'pending'); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + _shiftLabel( + schedule.shiftType, + rotationConfig, + ), + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith( + fontWeight: + FontWeight.w700, + ), + overflow: + TextOverflow.ellipsis, + ), + ), + if (schedule.swapRequestId != + null) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets + .symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors + .tertiaryContainer, + borderRadius: + BorderRadius.circular( + 6, + ), + ), + child: Text( + 'Swapped', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: colors + .onTertiaryContainer, + fontWeight: + FontWeight.w600, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + '${AppTime.formatDate(schedule.startTime)} · ${AppTime.formatTime(schedule.startTime)}-${AppTime.formatTime(schedule.endTime)}', + style: Theme.of(context) + .textTheme + .bodySmall, + ), + ], + ), + ), + if (!hasPendingSwap && + schedule.status != 'absent' && + isItStaff) + OutlinedButton.icon( + onPressed: () => + _showSwapDialog(context, ref, + schedule, profileById), + icon: const Icon(Icons.swap_horiz, + size: 18), + label: const Text('Swap'), + ) + else if (hasPendingSwap) + Chip( + label: const Text('Swap Pending'), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .primary, + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + if (pendingSwaps.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: + const EdgeInsets.only(left: 16, bottom: 8), + child: Text( + 'Swap Requests', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ), + // Group swaps by (requesterId, recipientId) so that a + // week/range batch appears as a single collapsible card. + Builder(builder: (context) { + final swapGroups = >{}; + for (final swap in pendingSwaps) { + final key = + '${swap.requesterId}_${swap.recipientId}'; + swapGroups.putIfAbsent(key, () => []).add(swap); + } + for (final group in swapGroups.values) { + group.sort((a, b) => + (a.shiftStartTime ?? a.createdAt).compareTo( + b.shiftStartTime ?? b.createdAt)); + } + final groupEntries = swapGroups.entries.toList() + ..sort((a, b) => a.value.first.createdAt + .compareTo(b.value.first.createdAt)); + + return SliverList.separated( + itemCount: groupEntries.length, + separatorBuilder: (context, i) => + const SizedBox(height: 8), + itemBuilder: (context, index) { + final entry = groupEntries[index]; + final groupKey = entry.key; + final group = entry.value; + + if (group.length == 1) { + // ── Single swap card (existing layout) ── + final swap = group.first; + final isRecipient = + swap.recipientId == currentUserId; + final isRequester = + swap.requesterId == currentUserId; + final requesterName = + profileById[swap.requesterId]?.fullName ?? + swap.requesterId; + final recipientName = + profileById[swap.recipientId]?.fullName ?? + swap.recipientId; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '$requesterName → $recipientName', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + '${_shiftLabel(swap.shiftType ?? 'normal', rotationConfig)} · ${AppTime.formatDate(swap.shiftStartTime ?? DateTime.now())} · ${AppTime.formatTime(swap.shiftStartTime ?? DateTime.now())}', + style: Theme.of(context) + .textTheme + .bodySmall, + ), + const SizedBox(height: 8), + Text( + swap.status == 'admin_review' + ? 'Awaiting admin approval' + : 'Pending', + style: Theme.of(context) + .textTheme + .labelSmall, + ), + const SizedBox(height: 12), + if (isRecipient && + swap.status == 'pending') ...[ + Row( + mainAxisAlignment: + MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () => _respondSwap( + context, + ref, + swap.id, + 'rejected'), + child: const Text('Reject'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => _respondSwap( + context, + ref, + swap.id, + 'accepted'), + child: const Text('Accept'), + ), + ], + ), + ] else if (isRequester && + swap.status == 'pending') ...[ + Align( + alignment: Alignment.centerRight, + child: OutlinedButton( + onPressed: () => _respondSwap( + context, + ref, + swap.id, + 'admin_review'), + child: const Text('Escalate'), + ), + ), + ], + ], + ), + ), + ), + ); + } + + // ── Multi-swap collapsible group card ── + final firstSwap = group.first; + final isRecipient = + firstSwap.recipientId == currentUserId; + final isRequester = + firstSwap.requesterId == currentUserId; + final requesterName = + profileById[firstSwap.requesterId]?.fullName ?? + firstSwap.requesterId; + final recipientName = + profileById[firstSwap.recipientId]?.fullName ?? + firstSwap.recipientId; + + final dates = group + .map((s) => s.shiftStartTime) + .whereType() + .toList() + ..sort(); + final dateLabel = dates.isEmpty + ? '' + : dates.length == 1 + ? AppTime.formatDate(dates.first) + : '${AppTime.formatDate(dates.first)} – ${AppTime.formatDate(dates.last)}'; + + final hasPending = + group.any((s) => s.status == 'pending'); + final isExpanded = + _expandedGroupKeys.contains(groupKey); + final statusLabel = group.every( + (s) => s.status == 'admin_review') + ? 'Awaiting admin approval' + : hasPending + ? 'Pending' + : 'Awaiting admin approval'; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16), + child: Card( + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + // ─ Tappable header ─ + InkWell( + onTap: () => setState(() { + if (isExpanded) { + _expandedGroupKeys.remove(groupKey); + } else { + _expandedGroupKeys.add(groupKey); + } + }), + child: Padding( + padding: const EdgeInsets.fromLTRB( + 12, 12, 12, 8), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '$requesterName → $recipientName', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith( + fontWeight: + FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + '${group.length} shifts${dateLabel.isNotEmpty ? ' · $dateLabel' : ''}', + style: Theme.of(context) + .textTheme + .bodySmall, + ), + const SizedBox(height: 4), + Text( + statusLabel, + style: Theme.of(context) + .textTheme + .labelSmall, + ), + ], + ), + ), + Icon( + isExpanded + ? Icons.expand_less + : Icons.expand_more, + size: 20, + ), + ], + ), + ), + ), + // ─ Batch action buttons ─ + if (isRecipient && hasPending) ...[ + Padding( + padding: const EdgeInsets.fromLTRB( + 12, 0, 12, 12), + child: Row( + mainAxisAlignment: + MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () => + _respondToGroup( + context, + ref, + group, + 'rejected'), + child: + const Text('Reject All'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => + _respondToGroup( + context, + ref, + group, + 'accepted'), + child: + const Text('Accept All'), + ), + ], + ), + ), + ] else if (isRequester && hasPending) ...[ + Padding( + padding: const EdgeInsets.fromLTRB( + 12, 0, 12, 12), + child: Align( + alignment: Alignment.centerRight, + child: OutlinedButton( + onPressed: () => + _respondToGroup( + context, + ref, + group, + 'admin_review'), + child: + const Text('Escalate All'), + ), + ), + ), + ], + // ─ Expanded individual rows ─ + if (isExpanded) ...[ + const Divider(height: 1), + ...group.map((swap) { + final rowIsRecipient = + swap.recipientId == currentUserId; + final rowIsRequester = + swap.requesterId == currentUserId; + return Padding( + padding: const EdgeInsets.fromLTRB( + 12, 8, 12, 8), + child: Row( + children: [ + Expanded( + child: Text( + '${_shiftLabel(swap.shiftType ?? 'normal', rotationConfig)} · ${AppTime.formatDate(swap.shiftStartTime ?? DateTime.now())} · ${AppTime.formatTime(swap.shiftStartTime ?? DateTime.now())}', + style: Theme.of(context) + .textTheme + .bodySmall, + ), + ), + if (rowIsRecipient && + swap.status == + 'pending') ...[ + const SizedBox(width: 8), + OutlinedButton( + style: OutlinedButton + .styleFrom( + padding: + const EdgeInsets + .symmetric( + horizontal: 10), + visualDensity: + VisualDensity.compact, + ), + onPressed: () => + _respondSwap( + context, + ref, + swap.id, + 'rejected'), + child: + const Text('Reject'), + ), + const SizedBox(width: 6), + FilledButton( + style: + FilledButton.styleFrom( + padding: + const EdgeInsets + .symmetric( + horizontal: 10), + visualDensity: + VisualDensity.compact, + ), + onPressed: () => + _respondSwap( + context, + ref, + swap.id, + 'accepted'), + child: + const Text('Accept'), + ), + ] else if (rowIsRequester && + swap.status == 'pending') ...[ + const SizedBox(width: 8), + OutlinedButton( + style: OutlinedButton + .styleFrom( + padding: + const EdgeInsets + .symmetric( + horizontal: 10), + visualDensity: + VisualDensity.compact, + ), + onPressed: () => + _respondSwap( + context, + ref, + swap.id, + 'admin_review'), + child: const Text( + 'Escalate'), + ), + ], + ], + ), + ); + }), + ], + ], + ), + ), + ); + }, + ); + }), + ], + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => AppErrorView( + error: error, + onRetry: () => ref.invalidate(dutySchedulesProvider), + ), + ), + ), + ], + ); + } + + 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'; + case 'pm': + return 'PM Duty'; + case 'on_call': + return 'On Call'; + case 'normal': + return 'Normal'; + case 'weekend': + return 'Weekend'; + default: + return value; + } + } + + void _showSwapDialog( + BuildContext context, + WidgetRef ref, + DutySchedule schedule, + Map profileById, + ) { + final isMobile = MediaQuery.sizeOf(context).width < 600; + if (isMobile) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (ctx, scrollController) => _SwapRequestSheet( + schedule: schedule, + profileById: profileById, + scrollController: scrollController, + ), + ), + ); + } else { + m3ShowDialog( + context: context, + builder: (_) => _SwapRequestSheet( + schedule: schedule, + profileById: profileById, + ), + ); + } + } + + Future _respondSwap( + BuildContext context, + WidgetRef ref, + String swapId, + String action, + ) async { + // Capture swap + profile data before the async call so we have it + // regardless of what the provider state is afterward. + final swap = ref + .read(swapRequestsProvider) + .valueOrNull + ?.where((s) => s.id == swapId) + .firstOrNull; + final profilesAsync = ref.read(profilesProvider).valueOrNull ?? []; + final profileById = {for (final p in profilesAsync) p.id: p}; + + try { + await ref + .read(workforceControllerProvider) + .respondSwap(swapId: swapId, action: action); + + // Hide the swap card immediately — don't wait for the stream to re-emit. + ref.read(locallyRemovedSwapIdsProvider.notifier).update((s) => {...s, swapId}); + + // Client-side orphan removal: when a swap is accepted the DB auto-rejects + // all other pending swaps for the same schedules. Mirror that here so the + // UI catches up instantly without waiting for the next stream emission. + if (action == 'accepted' && swap != null) { + final allSwaps = ref.read(swapRequestsProvider).valueOrNull ?? []; + final orphanIds = allSwaps + .where((s) => + s.id != swapId && + (s.requesterScheduleId == swap.requesterScheduleId || + s.requesterScheduleId == swap.targetScheduleId || + (swap.targetScheduleId != null && + s.targetScheduleId == swap.requesterScheduleId) || + (swap.targetScheduleId != null && + s.targetScheduleId == swap.targetScheduleId))) + .map((s) => s.id) + .toSet(); + if (orphanIds.isNotEmpty) { + ref + .read(locallyRemovedSwapIdsProvider.notifier) + .update((s) => {...s, ...orphanIds}); + } + } + + // Send push notifications if we have the swap details + if (swap != null) { + final notificationsController = + ref.read(notificationsControllerProvider); + final recipientName = + profileById[swap.recipientId]?.fullName ?? 'Someone'; + final shiftDate = swap.shiftStartTime != null + ? AppTime.formatDate(swap.shiftStartTime!) + : 'your shift'; + + if (action == 'accepted') { + await notificationsController.sendPush( + userIds: [swap.requesterId], + title: 'Swap accepted', + body: '$recipientName accepted your swap request for $shiftDate.', + data: {'type': 'swap_update', 'navigate_to': '/attendance'}, + ); + } else if (action == 'rejected') { + await notificationsController.sendPush( + userIds: [swap.requesterId], + title: 'Swap rejected', + body: '$recipientName rejected your swap request for $shiftDate.', + data: {'type': 'swap_update', 'navigate_to': '/attendance'}, + ); + } else if (action == 'admin_review') { + final adminIds = profilesAsync + .where((p) => + p.role == 'admin' || + p.role == 'programmer' || + p.role == 'dispatcher') + .map((p) => p.id) + .toList(); + final requesterName = + profileById[swap.requesterId]?.fullName ?? 'Someone'; + await notificationsController.sendPush( + userIds: adminIds, + title: 'Swap escalated for review', + body: '$requesterName escalated a swap request for $shiftDate.', + data: {'type': 'swap_request', 'navigate_to': '/workforce'}, + ); + } + } + + if (!context.mounted) return; + showSuccessSnackBar(context, 'Swap ${action.replaceAll('_', ' ')}.'); + } catch (e) { + // If the swap was already processed (ownership changed = already swapped, + // or not found = deleted), hide the card silently rather than surfacing + // a confusing error. The finally block below will refresh the stream. + final alreadyProcessed = + e.toString().contains('ownership') || + e.toString().contains('not found') || + e.toString().contains('already'); + if (alreadyProcessed) { + ref.read(locallyRemovedSwapIdsProvider.notifier).update((s) => {...s, swapId}); + } + if (!context.mounted) return; + if (!alreadyProcessed) { + showErrorSnackBar(context, 'Failed: $e'); + } + } finally { + // Refresh the swap list — the new provider fires an immediate REST poll + // so any device sees the latest status within milliseconds. + ref.invalidate(swapRequestsProvider); + ref.invalidate(dutySchedulesProvider); + } + } + + /// Batch-respond to a group of swap requests (Accept All / Reject All / + /// Escalate All). Optimistically removes the whole group from the UI, then + /// fires RPC calls sequentially (already-processed entries are silently + /// skipped thanks to the idempotency guard in the DB function). + Future _respondToGroup( + BuildContext context, + WidgetRef ref, + List group, + String action, + ) async { + // 1. Optimistic removal of the entire group. + ref.read(locallyRemovedSwapIdsProvider.notifier).update( + (s) => {...s, ...group.map((sw) => sw.id)}, + ); + + final profilesAsync = ref.read(profilesProvider).valueOrNull ?? []; + final profileById = {for (final p in profilesAsync) p.id: p}; + + int succeeded = 0; + for (final swap in group) { + try { + await ref + .read(workforceControllerProvider) + .respondSwap(swapId: swap.id, action: action); + succeeded++; + } catch (_) { + // Already processed or ownership changed — skip silently. + } + } + + // 2. One combined push notification for the batch. + if (succeeded > 0 && group.isNotEmpty) { + final firstSwap = group.first; + final dates = group + .map((sw) => sw.shiftStartTime) + .whereType() + .toList() + ..sort(); + final dateLabel = dates.isEmpty + ? 'your shifts' + : dates.length == 1 + ? AppTime.formatDate(dates.first) + : '${AppTime.formatDate(dates.first)} – ${AppTime.formatDate(dates.last)}'; + + final notificationsController = ref.read(notificationsControllerProvider); + final recipientName = + profileById[firstSwap.recipientId]?.fullName ?? 'Someone'; + + if (action == 'accepted') { + await notificationsController.sendPush( + userIds: [firstSwap.requesterId], + title: 'Swaps accepted', + body: '$recipientName accepted your swap request for $dateLabel.', + data: {'type': 'swap_update', 'navigate_to': '/attendance'}, + ); + } else if (action == 'rejected') { + await notificationsController.sendPush( + userIds: [firstSwap.requesterId], + title: 'Swaps rejected', + body: '$recipientName rejected your swap request for $dateLabel.', + data: {'type': 'swap_update', 'navigate_to': '/attendance'}, + ); + } else if (action == 'admin_review') { + final adminIds = profilesAsync + .where((p) => + p.role == 'admin' || + p.role == 'programmer' || + p.role == 'dispatcher') + .map((p) => p.id) + .toList(); + final requesterName = + profileById[firstSwap.requesterId]?.fullName ?? 'Someone'; + await notificationsController.sendPush( + userIds: adminIds, + title: 'Swaps escalated for review', + body: '$requesterName escalated swap requests for $dateLabel.', + data: {'type': 'swap_request', 'navigate_to': '/workforce'}, + ); + } + } + + ref.invalidate(swapRequestsProvider); + ref.invalidate(dutySchedulesProvider); + + if (!context.mounted) return; + showSuccessSnackBar( + context, + '${group.length} swap${group.length == 1 ? '' : 's'} ${action.replaceAll('_', ' ')}.', + ); + } +} + +// ──────────────────────────────────────────────── +// Swap Request Sheet for My Schedule Tab +// ──────────────────────────────────────────────── + +class _SwapRequestSheet extends ConsumerStatefulWidget { + const _SwapRequestSheet({ + required this.schedule, + required this.profileById, + this.scrollController, + }); + + final DutySchedule schedule; + final Map profileById; + final ScrollController? scrollController; + + @override + ConsumerState<_SwapRequestSheet> createState() => + _SwapRequestSheetState(); +} + +enum _SwapType { singleDay, wholeWeek, dateRange } + +class _SwapRequestSheetState extends ConsumerState<_SwapRequestSheet> { + late _SwapType swapType = _SwapType.singleDay; + String? selectedRecipientId; + DateTimeRange? selectedDateRange; + // 1 Day mode: requester and recipient may pick DIFFERENT dates. + DateTime? selectedRequesterDay; + DateTime? selectedRecipientDay; + DateTimeRange? selectedWeek; + bool _submitting = false; + + @override + void initState() { + super.initState(); + selectedRequesterDay = widget.schedule.startTime; + selectedRecipientDay = widget.schedule.startTime; + } + + /// Returns the requester's non-overtime/non-on_call schedule on [day], or + /// null if no schedule exists for that date. + DutySchedule? _scheduleOnDay(List schedules, String userId, DateTime day) { + return schedules.where((s) => + s.userId == userId && + s.shiftType != 'overtime' && + s.shiftType != 'on_call' && + s.startTime.year == day.year && + s.startTime.month == day.month && + s.startTime.day == day.day, + ).firstOrNull; + } + + List _buildUpcomingWeeks(List mySchedules) { + final today = DateTime.now(); + final todayNormalized = DateTime(today.year, today.month, today.day); + + // Compute unique weeks from schedules that are today or in the future + final weekStarts = {}; + for (final s in mySchedules) { + // Only include schedules that start today or later + final scheduleDate = DateTime(s.startTime.year, s.startTime.month, s.startTime.day); + if (scheduleDate.isBefore(todayNormalized)) { + continue; // Skip past schedules + } + + final startDate = s.startTime; + final mondayOffset = startDate.weekday - 1; + final monday = startDate.subtract(Duration(days: mondayOffset)); + final mondayNormalized = DateTime(monday.year, monday.month, monday.day); + + // Only add if the Monday is today or in the future + if (!mondayNormalized.isBefore(todayNormalized)) { + weekStarts.add(mondayNormalized); + } + } + + // Convert to DateTimeRange and sort + final result = weekStarts + .map((monday) => DateTimeRange( + start: monday, + end: monday.add(const Duration(days: 6)), + )) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + return result; + } + + DateTimeRange _getEffectiveDateRange() { + switch (swapType) { + case _SwapType.singleDay: + // For single-day mode the date range is used only by the recipient + // schedule preview (not by _submitSwapRequest, which uses the two + // day fields directly). + final day = selectedRequesterDay ?? widget.schedule.startTime; + return DateTimeRange(start: day, end: day); + case _SwapType.wholeWeek: + return selectedWeek ?? + DateTimeRange( + start: widget.schedule.startTime, + end: widget.schedule.startTime.add(const Duration(days: 6)), + ); + case _SwapType.dateRange: + return selectedDateRange ?? + DateTimeRange( + start: widget.schedule.startTime, + end: widget.schedule.startTime.add(const Duration(days: 7)), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final profilesAsync = ref.watch(profilesProvider); + final mySchedulesAsync = ref.watch(dutySchedulesProvider); + final currentUserId = ref.read(currentUserIdProvider); + + // Get IT staff profiles for recipient dropdown + final itStaff = (profilesAsync.valueOrNull ?? []) + .where((p) => p.role == 'it_staff' && p.id != currentUserId) + .toList() + ..sort((a, b) => a.fullName.compareTo(b.fullName)); + + final mySchedules = mySchedulesAsync.valueOrNull ?? []; + final upcomingWeeks = _buildUpcomingWeeks( + mySchedules.where((s) => s.userId == currentUserId).toList(), + ); + + final effectiveDateRange = _getEffectiveDateRange(); + + final isBottomSheet = widget.scrollController != null; + final dialogShape = isBottomSheet ? null : AppSurfaces.of(context).dialogShape; + + Widget content = SingleChildScrollView( + controller: widget.scrollController, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Swap Type Selection + Text( + 'Swap Type', + style: theme.textTheme.labelMedium, + ), + const SizedBox(height: 8), + SegmentedButton<_SwapType>( + segments: const [ + ButtonSegment<_SwapType>( + value: _SwapType.singleDay, + label: Text('1 Day'), + ), + ButtonSegment<_SwapType>( + value: _SwapType.wholeWeek, + label: Text('Week'), + ), + ButtonSegment<_SwapType>( + value: _SwapType.dateRange, + label: Text('Range'), + ), + ], + selected: {swapType}, + onSelectionChanged: (Set<_SwapType> newSelection) { + setState(() => swapType = newSelection.first); + }, + ), + const SizedBox(height: 16), + + // Date Selection UI based on swap type + Text( + 'Select Date', + style: theme.textTheme.labelMedium, + ), + const SizedBox(height: 8), + if (swapType == _SwapType.singleDay) ...[ + // ── "Your shift" date picker ────────────────────────────────── + _DatePickerRow( + label: 'Your shift', + date: selectedRequesterDay ?? widget.schedule.startTime, + schedule: _scheduleOnDay( + mySchedules, + currentUserId ?? '', + selectedRequesterDay ?? widget.schedule.startTime, + ), + onPick: () async { + final picked = await showDatePicker( + context: context, + initialDate: selectedRequesterDay ?? widget.schedule.startTime, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 90)), + ); + if (picked != null) setState(() => selectedRequesterDay = picked); + }, + ), + const SizedBox(height: 8), + // ── "Their shift" date picker ───────────────────────────────── + _DatePickerRow( + label: 'Their shift', + date: selectedRecipientDay ?? widget.schedule.startTime, + schedule: selectedRecipientId == null + ? null + : _scheduleOnDay( + mySchedules, + selectedRecipientId!, + selectedRecipientDay ?? widget.schedule.startTime, + ), + noScheduleLabel: + selectedRecipientId == null ? 'Select a recipient first' : null, + onPick: () async { + final picked = await showDatePicker( + context: context, + initialDate: selectedRecipientDay ?? widget.schedule.startTime, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 90)), + ); + if (picked != null) setState(() => selectedRecipientDay = picked); + }, + ), + ] else if (swapType == _SwapType.wholeWeek) ...[ + if (upcomingWeeks.isEmpty) + Text( + 'No upcoming weeks found', + style: theme.textTheme.bodySmall, + ) + else + Column( + children: [ + for (final week in upcomingWeeks) + RadioListTile( + title: Text( + '${AppTime.formatDate(week.start)} – ${AppTime.formatDate(week.end)}', + ), + value: week, + groupValue: selectedWeek, // ignore: deprecated_member_use + onChanged: (value) { // ignore: deprecated_member_use + if (value != null) { + setState(() => selectedWeek = value); + } + }, + ), + ], + ), + ] else if (swapType == _SwapType.dateRange) ...[ + Text( + '${AppTime.formatDate(effectiveDateRange.start)} – ${AppTime.formatDate(effectiveDateRange.end)}', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () async { + final picked = await showDateRangePicker( + context: context, + firstDate: widget.schedule.startTime, + lastDate: widget.schedule.startTime.add(const Duration(days: 90)), + initialDateRange: effectiveDateRange, + ); + if (picked != null) { + setState(() => selectedDateRange = picked); + } + }, + child: const Text('Change Range'), + ), + ], + const SizedBox(height: 16), + + // Recipient Selection + Text( + 'Select Recipient', + style: theme.textTheme.labelMedium, + ), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: selectedRecipientId, + items: [ + for (final profile in itStaff) + DropdownMenuItem( + value: profile.id, + child: Text(profile.fullName), + ), + ], + onChanged: (value) => setState(() => selectedRecipientId = value), + decoration: const InputDecoration( + labelText: 'Select staff member', + ), + ), + const SizedBox(height: 16), + + // Recipient's schedule preview (week/range modes only — 1 Day shows + // the preview inline in the _DatePickerRow widgets above). + if (swapType != _SwapType.singleDay && selectedRecipientId != null) ...[ + Text( + "Recipient's schedule", + style: theme.textTheme.labelMedium, + ), + const SizedBox(height: 8), + ref + .watch(dutySchedulesForUserProvider(selectedRecipientId!)) + .when( + data: (shifts) { + final inRange = shifts.where((s) { + final sStart = s.startTime; + final rangeStart = effectiveDateRange.start; + final rangeEnd = effectiveDateRange.end.add(const Duration(days: 1)); + return !sStart.isBefore(rangeStart) && sStart.isBefore(rangeEnd); + }).toList(); + + if (inRange.isEmpty) { + return Text( + 'No schedules in this range', + style: theme.textTheme.bodySmall, + ); + } + + return Column( + children: [ + for (final s in inRange) + ListTile( + dense: true, + leading: const Icon(Icons.schedule, size: 20), + title: Text(s.shiftType), + subtitle: Text( + '${AppTime.formatDate(s.startTime)} · ${AppTime.formatTime(s.startTime)}–${AppTime.formatTime(s.endTime)}', + ), + ), + ], + ); + }, + loading: () => const LinearProgressIndicator(), + error: (e, st) => Text( + 'Could not load schedule', + style: theme.textTheme.bodySmall, + ), + ), + ], + const SizedBox(height: 16), + ], + ), + ); + + // For "1 Day" mode, disable submit if either schedule is missing. + final singleDayReady = swapType != _SwapType.singleDay || + (selectedRecipientId != null && + _scheduleOnDay( + mySchedules, + currentUserId ?? '', + selectedRequesterDay ?? widget.schedule.startTime, + ) != + null && + _scheduleOnDay( + mySchedules, + selectedRecipientId!, + selectedRecipientDay ?? widget.schedule.startTime, + ) != + null); + + // Action buttons + final actions = [ + TextButton( + onPressed: _submitting ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _submitting || selectedRecipientId == null || !singleDayReady + ? null + : () => _submitSwapRequest(context, effectiveDateRange), + child: const Text('Send Request'), + ), + ]; + + if (isBottomSheet) { + return Scaffold( + appBar: AppBar( + title: const Text('Request Shift Swap'), + leading: const SizedBox(), + ), + body: content, + bottomNavigationBar: Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: actions, + ), + ), + ); + } else { + return AlertDialog( + shape: dialogShape, + title: const Text('Request Shift Swap'), + content: content, + actions: actions, + ); + } + } + + Future _submitSwapRequest( + BuildContext context, + DateTimeRange dateRange, + ) async { + if (selectedRecipientId == null) return; + + setState(() => _submitting = true); + + try { + // Get all schedules for the requester (current user) in the date range + final currentUserId = ref.read(currentUserIdProvider); + final allSchedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; + + // Collect all matching requester↔recipient schedule pairs. + final profilesAsync = ref.read(profilesProvider).valueOrNull ?? []; + final profileById = {for (final p in profilesAsync) p.id: p}; + final requesterName = profileById[currentUserId]?.fullName ?? 'Someone'; + final notificationsController = ref.read(notificationsControllerProvider); + + final matchedPairs = + <({DutySchedule requester, DutySchedule recipient})>[]; + + if (swapType == _SwapType.singleDay) { + // Cross-date single-day swap: requester and recipient may pick + // DIFFERENT calendar days. Look them up independently. + final reqDay = selectedRequesterDay ?? widget.schedule.startTime; + final recDay = selectedRecipientDay ?? widget.schedule.startTime; + + final reqSchedule = _scheduleOnDay(allSchedules, currentUserId ?? '', reqDay); + final recSchedule = _scheduleOnDay(allSchedules, selectedRecipientId!, recDay); + + if (reqSchedule == null || recSchedule == null) { + if (mounted) { + showErrorSnackBar( + context, + reqSchedule == null + ? 'You have no schedule on ${AppTime.formatDate(reqDay)}.' + : 'Recipient has no schedule on ${AppTime.formatDate(recDay)}.', + ); + } + return; + } + matchedPairs.add((requester: reqSchedule, recipient: recSchedule)); + } else { + // Week / date-range mode: pair schedules that fall on the same + // calendar day within the selected range. + // on_call is excluded — it travels with PM automatically via DB. + final requesterSchedulesInRange = allSchedules + .where((s) => + s.userId == currentUserId && + s.shiftType != 'overtime' && + s.shiftType != 'on_call' && + !s.startTime.isBefore(dateRange.start) && + !s.endTime.isAfter(dateRange.end.add(const Duration(days: 1)))) + .toList() + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + + final recipientSchedulesInRange = allSchedules + .where((s) => + s.userId == selectedRecipientId && + s.shiftType != 'overtime' && + s.shiftType != 'on_call' && + !s.startTime.isBefore(dateRange.start) && + !s.endTime.isAfter(dateRange.end.add(const Duration(days: 1)))) + .toList() + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + + for (final requesterSchedule in requesterSchedulesInRange) { + final matching = recipientSchedulesInRange.where( + (s) => + s.startTime.year == requesterSchedule.startTime.year && + s.startTime.month == requesterSchedule.startTime.month && + s.startTime.day == requesterSchedule.startTime.day, + ); + if (matching.isNotEmpty) { + matchedPairs + .add((requester: requesterSchedule, recipient: matching.first)); + } + } + } + + // Submit all swap requests + for (final pair in matchedPairs) { + await ref.read(workforceControllerProvider).requestSwap( + requesterScheduleId: pair.requester.id, + targetScheduleId: pair.recipient.id, + recipientId: selectedRecipientId!, + ); + } + + // Send ONE push notification summarising all matched dates + if (matchedPairs.isNotEmpty) { + final dates = + matchedPairs.map((p) => p.recipient.startTime).toList()..sort(); + final dateLabel = dates.length == 1 + ? AppTime.formatDate(dates.first) + : '${AppTime.formatDate(dates.first)} – ${AppTime.formatDate(dates.last)}'; + await notificationsController.sendPush( + userIds: [selectedRecipientId!], + title: 'Shift swap request', + body: '$requesterName wants to swap shifts on $dateLabel.', + data: {'type': 'swap_request', 'navigate_to': '/attendance'}, + ); + } + + final swapCount = matchedPairs.length; + + ref.invalidate(swapRequestsProvider); + if (!context.mounted) return; + Navigator.pop(context); + showSuccessSnackBar( + context, + swapCount == 1 + ? 'Swap request sent!' + : '$swapCount swap requests sent!', + ); + } catch (e) { + if (!context.mounted) return; + showErrorSnackBar(context, 'Failed to send swap request: $e'); + } finally { + if (mounted) { + setState(() => _submitting = false); + } + } + } +} + +// ──────────────────────────────────────────────── +// Tab 4 – Pass Slip // ──────────────────────────────────────────────── class _PassSlipTab extends ConsumerStatefulWidget { @@ -3481,66 +4886,80 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { children: [ // Active slip banner if (activeSlip != null) ...[ - Card( - color: activeSlip.isExceeded - ? colors.errorContainer - : colors.tertiaryContainer, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - activeSlip.isExceeded - ? Icons.warning - : Icons.directions_walk, - color: activeSlip.isExceeded - ? colors.onErrorContainer - : colors.onTertiaryContainer, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - activeSlip.isExceeded - ? 'Pass Slip Exceeded (>1 hour)' - : 'Active Pass Slip', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: activeSlip.isExceeded - ? colors.onErrorContainer - : colors.onTertiaryContainer, + Builder(builder: (context) { + final hasStarted = activeSlip.slipStart == null || + AppTime.now().isAfter(activeSlip.slipStart!); + final Color cardColor; + final Color onCardColor; + final IconData cardIcon; + final String cardTitle; + + if (activeSlip.isExceeded) { + cardColor = colors.errorContainer; + onCardColor = colors.onErrorContainer; + cardIcon = Icons.warning; + cardTitle = 'Pass Slip Exceeded (>1 hour)'; + } else if (!hasStarted) { + cardColor = colors.primaryContainer; + onCardColor = colors.onPrimaryContainer; + cardIcon = Icons.schedule; + cardTitle = + 'Pass Slip Approved — Starts at ${AppTime.formatTime(activeSlip.slipStart!)}'; + } else { + cardColor = colors.tertiaryContainer; + onCardColor = colors.onTertiaryContainer; + cardIcon = Icons.directions_walk; + cardTitle = 'Active Pass Slip'; + } + + return Card( + color: cardColor, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(cardIcon, color: onCardColor), + const SizedBox(width: 8), + Expanded( + child: Text( + cardTitle, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: onCardColor, + ), ), ), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Reason: ${activeSlip.reason}', - style: theme.textTheme.bodyMedium, - ), - if (activeSlip.slipStart != null) + ], + ), + const SizedBox(height: 8), Text( - 'Started: ${AppTime.formatTime(activeSlip.slipStart!)}', - style: theme.textTheme.bodySmall, + 'Reason: ${activeSlip.reason}', + style: theme.textTheme.bodyMedium, ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _submitting - ? null - : () => _completeSlip(activeSlip.id), - icon: const Icon(Icons.check), - label: const Text('Complete / Return'), + if (activeSlip.slipStart != null && hasStarted) + Text( + 'Started: ${AppTime.formatTime(activeSlip.slipStart!)}', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _submitting + ? null + : () => _completeSlip(activeSlip.id), + icon: const Icon(Icons.check), + label: const Text('Complete / Return'), + ), ), - ), - ], + ], + ), ), - ), - ), + ); + }), const SizedBox(height: 16), ], @@ -3676,6 +5095,13 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> { color: colors.onSurfaceVariant, ), ), + if (slip.requestedStart != null) + Text( + 'Preferred start: ${AppTime.formatTime(slip.requestedStart!)}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), if (slip.slipStart != null) Text( 'Started: ${AppTime.formatTime(slip.slipStart!)}' @@ -4145,6 +5571,7 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> { final _reasonController = TextEditingController(); bool _submitting = false; bool _isGeminiProcessing = false; + TimeOfDay? _requestedStartTime; @override void dispose() { @@ -4160,9 +5587,22 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> { } setState(() => _submitting = true); try { - await ref - .read(passSlipControllerProvider) - .requestSlip(dutyScheduleId: widget.scheduleId, reason: reason); + DateTime? requestedStart; + if (_requestedStartTime != null) { + final now = AppTime.now(); + requestedStart = AppTime.fromComponents( + year: now.year, + month: now.month, + day: now.day, + hour: _requestedStartTime!.hour, + minute: _requestedStartTime!.minute, + ); + } + await ref.read(passSlipControllerProvider).requestSlip( + dutyScheduleId: widget.scheduleId, + reason: reason, + requestedStart: requestedStart, + ); if (mounted) { Navigator.of(context).pop(); widget.onSubmitted(); @@ -4221,6 +5661,52 @@ class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> { ), ], ), + const SizedBox(height: 16), + // Optional start time picker + InkWell( + borderRadius: BorderRadius.circular(12), + onTap: _submitting + ? null + : () async { + final picked = await showTimePicker( + context: context, + initialTime: + _requestedStartTime ?? TimeOfDay.now(), + ); + if (picked != null && mounted) { + setState(() => _requestedStartTime = picked); + } + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: 'Preferred start time (optional)', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: const Icon(Icons.schedule), + suffixIcon: _requestedStartTime != null + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: _submitting + ? null + : () => setState( + () => _requestedStartTime = null), + tooltip: 'Clear', + ) + : null, + ), + child: Text( + _requestedStartTime != null + ? _requestedStartTime!.format(context) + : 'Immediately upon approval', + style: theme.textTheme.bodyLarge?.copyWith( + color: _requestedStartTime != null + ? null + : theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.end, @@ -4574,3 +6060,115 @@ class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> { ); } } + +// ──────────────────────────────────────────────────────────────────────────── +// Helper widget: single date picker row with a live schedule preview chip. +// Used inside the "1 Day" mode of _SwapRequestSheetState. +// ──────────────────────────────────────────────────────────────────────────── + +class _DatePickerRow extends StatelessWidget { + const _DatePickerRow({ + required this.label, + required this.date, + required this.onPick, + this.schedule, + this.noScheduleLabel, + }); + + final String label; + final DateTime date; + final VoidCallback onPick; + /// The resolved schedule for [date], or null if not found. + final DutySchedule? schedule; + /// Override message shown when no schedule found (e.g. "Select a recipient first"). + final String? noScheduleLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final hasSchedule = schedule != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.labelSmall), + const SizedBox(height: 2), + Text( + AppTime.formatDate(date), + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + OutlinedButton( + onPressed: onPick, + child: const Text('Pick Date'), + ), + ], + ), + const SizedBox(height: 4), + if (hasSchedule) + Row( + children: [ + Icon(Icons.schedule, size: 14, color: colors.primary), + const SizedBox(width: 4), + Flexible( + child: Text( + '${_shiftDisplayName(schedule!.shiftType)} · ' + '${AppTime.formatTime(schedule!.startTime)} – ' + '${AppTime.formatTime(schedule!.endTime)}', + style: theme.textTheme.bodySmall + ?.copyWith(color: colors.primary), + ), + ), + ], + ) + else + Row( + children: [ + Icon(Icons.warning_amber_rounded, + size: 14, color: colors.error), + const SizedBox(width: 4), + Flexible( + child: Text( + noScheduleLabel ?? 'No schedule on this date', + style: theme.textTheme.bodySmall + ?.copyWith(color: colors.error), + ), + ), + ], + ), + ], + ); + } + + static String _shiftDisplayName(String type) { + switch (type) { + case 'normal': + return 'Normal'; + case 'am': + return 'AM'; + case 'pm': + return 'PM'; + case 'on_call': + return 'On Call'; + case 'weekend': + return 'Weekend'; + case 'overtime': + return 'Overtime'; + case 'on_call_saturday': + return 'On Call (Sat)'; + case 'on_call_sunday': + return 'On Call (Sun)'; + default: + return type; + } + } +} diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index 42ce25fa..d640ccb2 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -14,6 +14,7 @@ import '../../providers/rotation_config_provider.dart'; import '../../providers/workforce_provider.dart'; import '../../providers/chat_provider.dart'; import '../../providers/ramadan_provider.dart'; +import '../../providers/notifications_provider.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/app_state_view.dart'; import '../../widgets/responsive_body.dart'; @@ -49,6 +50,9 @@ class WorkforceScreen extends ConsumerWidget { ); if (isWide) { + if (!isAdmin) { + return schedulePanel; + } return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -58,8 +62,8 @@ class WorkforceScreen extends ConsumerWidget { flex: 2, child: Column( children: [ - if (isAdmin) generatorPanel, - if (isAdmin) const SizedBox(height: 16), + generatorPanel, + const SizedBox(height: 16), Expanded(child: swapsPanel), ], ), @@ -68,16 +72,19 @@ class WorkforceScreen extends ConsumerWidget { ); } + if (!isAdmin) { + return schedulePanel; + } return DefaultTabController( - length: isAdmin ? 3 : 2, + length: 3, child: Column( children: [ const SizedBox(height: 8), - TabBar( + const TabBar( tabs: [ - const Tab(text: 'Schedule'), - const Tab(text: 'Swaps'), - if (isAdmin) const Tab(text: 'Generator'), + Tab(text: 'Schedule'), + Tab(text: 'Swaps'), + Tab(text: 'Generator'), ], ), const SizedBox(height: 8), @@ -86,7 +93,7 @@ class WorkforceScreen extends ConsumerWidget { children: [ schedulePanel, swapsPanel, - if (isAdmin) generatorPanel, + generatorPanel, ], ), ), @@ -314,23 +321,6 @@ class _ScheduleTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentUserId = ref.watch(currentUserIdProvider); - // Use .select() so this tile only rebuilds when its own swap status changes, - // not every time any swap in the list is updated. - final hasRequestedSwap = ref.watch( - swapRequestsProvider.select( - (async) => (async.valueOrNull ?? const []).any( - (swap) => - swap.requesterScheduleId == schedule.id && - swap.requesterId == currentUserId && - swap.status == 'pending', - ), - ), - ); - final now = AppTime.now(); - final isPast = schedule.startTime.isBefore(now); - final canRequestSwap = isMine && schedule.status != 'absent' && !isPast; - final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; ShiftTypeConfig? shiftTypeConfig; @@ -442,16 +432,6 @@ class _ScheduleTile extends ConsumerWidget { onPressed: () => _editSchedule(context, ref), icon: const Icon(Icons.edit, size: 20), ), - if (canRequestSwap) - OutlinedButton.icon( - onPressed: hasRequestedSwap - ? () => _openSwapsTab(context) - : () => _requestSwap(context, ref, schedule), - icon: const Icon(Icons.swap_horiz), - label: Text( - hasRequestedSwap ? 'Swap Requested' : 'Request swap', - ), - ), ], ), ], @@ -701,200 +681,8 @@ class _ScheduleTile extends ConsumerWidget { } } - Future _requestSwap( - BuildContext context, - WidgetRef ref, - DutySchedule schedule, - ) async { - final profiles = ref.read(profilesProvider).valueOrNull ?? []; - final currentUserId = ref.read(currentUserIdProvider); - final staff = profiles - .where((profile) => profile.role == 'it_staff') - .where((profile) => profile.id != currentUserId) - .toList(); - if (staff.isEmpty) { - _showMessage( - context, - 'No IT staff available for swaps.', - type: SnackType.warning, - ); - return; - } - String? selectedId = staff.first.id; - List recipientShifts = []; - String? selectedTargetShiftId; - final confirmed = await m3ShowDialog( - context: context, - builder: (dialogContext) { - return StatefulBuilder( - builder: (context, setState) { - // initial load for the first recipient shown — only upcoming shifts - if (recipientShifts.isEmpty && selectedId != null) { - ref - .read(dutySchedulesForUserProvider(selectedId!).future) - .then((shifts) { - final now = AppTime.now(); - final upcoming = - shifts.where((s) => !s.startTime.isBefore(now)).toList() - ..sort((a, b) => a.startTime.compareTo(b.startTime)); - setState(() { - recipientShifts = upcoming; - selectedTargetShiftId = upcoming.isNotEmpty - ? upcoming.first.id - : null; - }); - }) - .catchError((_) {}); - } - - return AlertDialog( - shape: AppSurfaces.of(context).dialogShape, - title: const Text('Request swap'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - DropdownButtonFormField( - initialValue: selectedId, - items: [ - for (final profile in staff) - DropdownMenuItem( - value: profile.id, - child: Text( - profile.fullName.isNotEmpty - ? profile.fullName - : profile.id, - ), - ), - ], - onChanged: (value) async { - if (value == null) return; - setState(() => selectedId = value); - // load recipient shifts (only show upcoming) - final shifts = await ref - .read(dutySchedulesForUserProvider(value).future) - .catchError((_) => []); - final now = AppTime.now(); - final upcoming = - shifts - .where((s) => !s.startTime.isBefore(now)) - .toList() - ..sort( - (a, b) => a.startTime.compareTo(b.startTime), - ); - setState(() { - recipientShifts = upcoming; - selectedTargetShiftId = upcoming.isNotEmpty - ? upcoming.first.id - : null; - }); - }, - decoration: const InputDecoration(labelText: 'Recipient'), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: selectedTargetShiftId, - items: [ - for (final s in recipientShifts) - DropdownMenuItem( - value: s.id, - child: Text( - '${s.shiftType == 'am' - ? 'AM Duty' - : s.shiftType == 'pm' - ? 'PM Duty' - : s.shiftType} · ${AppTime.formatDate(s.startTime)} · ${AppTime.formatTime(s.startTime)}', - ), - ), - ], - onChanged: (value) => - setState(() => selectedTargetShiftId = value), - decoration: const InputDecoration( - labelText: 'Recipient shift', - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('Send request'), - ), - ], - ); - }, - ); - }, - ); - - if (!context.mounted) { - return; - } - if (confirmed != true || - selectedId == null || - selectedTargetShiftId == null) { - return; - } - - try { - await ref - .read(workforceControllerProvider) - .requestSwap( - requesterScheduleId: schedule.id, - targetScheduleId: selectedTargetShiftId!, - recipientId: selectedId!, - ); - ref.invalidate(swapRequestsProvider); - if (!context.mounted) return; - _showMessage(context, 'Swap request sent.', type: SnackType.success); - } catch (error) { - if (!context.mounted) return; - _showMessage( - context, - 'Swap request failed: $error', - type: SnackType.error, - ); - } - } - - void _showMessage( - BuildContext context, - String message, { - SnackType type = SnackType.warning, - }) { - switch (type) { - case SnackType.success: - showSuccessSnackBar(context, message); - break; - case SnackType.error: - showErrorSnackBar(context, message); - break; - case SnackType.info: - showInfoSnackBar(context, message); - break; - case SnackType.warning: - showWarningSnackBar(context, message); - break; - } - } - - void _openSwapsTab(BuildContext context) { - final controller = DefaultTabController.maybeOf(context); - if (controller != null) { - controller.animateTo(1); - return; - } - _showMessage( - context, - 'Swap request already sent. See Swaps panel.', - type: SnackType.info, - ); - } String _statusLabel(String status) { switch (status) { @@ -2347,6 +2135,7 @@ class _SwapRequestsPanel extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final swapsAsync = ref.watch(swapRequestsProvider); + final removedSwapIds = ref.watch(locallyRemovedSwapIdsProvider); final schedulesAsync = ref.watch(dutySchedulesProvider); final profilesAsync = ref.watch(profilesProvider); final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; @@ -2362,7 +2151,12 @@ class _SwapRequestsPanel extends ConsumerWidget { }; return swapsAsync.when( - data: (items) { + data: (allItems) { + // Immediately exclude locally acted-on swap IDs while waiting for the + // stream to catch up (avoids stale cards flashing back after invalidation). + final items = allItems + .where((s) => !removedSwapIds.contains(s.id)) + .toList(); if (items.isEmpty) { return const Center(child: Text('No swap requests.')); } @@ -2534,7 +2328,31 @@ class _SwapRequestsPanel extends ConsumerWidget { await ref .read(workforceControllerProvider) .respondSwap(swapId: request.id, action: action); + ref.read(locallyRemovedSwapIdsProvider.notifier).update((s) => {...s, request.id}); ref.invalidate(swapRequestsProvider); + ref.invalidate(dutySchedulesProvider); + + // Send push notifications with date context + final notificationsController = ref.read(notificationsControllerProvider); + final shiftDate = request.shiftStartTime != null + ? AppTime.formatDate(request.shiftStartTime!) + : 'the shift'; + + if (action == 'accepted') { + await notificationsController.sendPush( + userIds: [request.requesterId, request.recipientId], + title: 'Swap approved', + body: 'An admin approved the swap for $shiftDate.', + data: {'type': 'swap_update', 'navigate_to': '/attendance'}, + ); + } else if (action == 'rejected') { + await notificationsController.sendPush( + userIds: [request.requesterId], + title: 'Swap rejected by admin', + body: 'An admin rejected your swap request for $shiftDate.', + data: {'type': 'swap_update', 'navigate_to': '/attendance'}, + ); + } } Future _changeRecipient( diff --git a/lib/utils/app_time.dart b/lib/utils/app_time.dart index c84ca2fb..9d3b3e74 100644 --- a/lib/utils/app_time.dart +++ b/lib/utils/app_time.dart @@ -61,6 +61,21 @@ class AppTime { /// Renders a [DateTime] in 12‑hour clock notation with AM/PM suffix. /// /// Example: **08:30 PM**. Used primarily in workforce-related screens. + /// Creates a [DateTime] in the app timezone (Asia/Manila) from date/time components. + /// + /// Use this instead of [DateTime] constructor when building a Manila-aware + /// timestamp from separate year/month/day/hour/minute values so that + /// `.toUtc()` correctly accounts for the +08:00 offset. + static DateTime fromComponents({ + required int year, + required int month, + required int day, + int hour = 0, + int minute = 0, + }) { + return tz.TZDateTime(tz.local, year, month, day, hour, minute); + } + static String formatTime(DateTime value) { final rawHour = value.hour; final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft( diff --git a/lib/widgets/pass_slip_countdown_banner.dart b/lib/widgets/pass_slip_countdown_banner.dart index b836fc31..d19f7056 100644 --- a/lib/widgets/pass_slip_countdown_banner.dart +++ b/lib/widgets/pass_slip_countdown_banner.dart @@ -104,20 +104,37 @@ class _PassSlipCountdownBannerState return widget.child; } - final isUrgent = !_exceeded && _remaining.inMinutes < 5; - final bgColor = _exceeded || isUrgent - ? Theme.of(context).colorScheme.errorContainer - : Theme.of(context).colorScheme.tertiaryContainer; - final fgColor = _exceeded || isUrgent - ? Theme.of(context).colorScheme.onErrorContainer - : Theme.of(context).colorScheme.onTertiaryContainer; + final now = DateTime.now(); + final hasStarted = now.isAfter(activeSlip.slipStart!) || + now.isAtSameMomentAs(activeSlip.slipStart!); + final bool isUrgent; + final Color bgColor; + final Color fgColor; final String message; final IconData icon; + if (_exceeded) { + isUrgent = true; + bgColor = Theme.of(context).colorScheme.errorContainer; + fgColor = Theme.of(context).colorScheme.onErrorContainer; message = 'Pass slip time EXCEEDED — Please return and complete it'; icon = Icons.warning_amber_rounded; + } else if (!hasStarted) { + isUrgent = false; + bgColor = Theme.of(context).colorScheme.primaryContainer; + fgColor = Theme.of(context).colorScheme.onPrimaryContainer; + final untilStart = activeSlip.slipStart!.difference(now); + message = 'Pass slip starts in ${_formatDuration(untilStart)}'; + icon = Icons.schedule_rounded; } else { + isUrgent = !_exceeded && _remaining.inMinutes < 5; + bgColor = isUrgent + ? Theme.of(context).colorScheme.errorContainer + : Theme.of(context).colorScheme.tertiaryContainer; + fgColor = isUrgent + ? Theme.of(context).colorScheme.onErrorContainer + : Theme.of(context).colorScheme.onTertiaryContainer; message = 'Pass slip expires in ${_formatDuration(_remaining)}'; icon = Icons.directions_walk_rounded; } diff --git a/supabase/functions/process_scheduled_notifications/index.ts b/supabase/functions/process_scheduled_notifications/index.ts index ddb249cc..bbf2997e 100644 --- a/supabase/functions/process_scheduled_notifications/index.ts +++ b/supabase/functions/process_scheduled_notifications/index.ts @@ -143,6 +143,11 @@ async function processBatch() { body = 'Your pass slip expires in 15 minutes. Please return and complete it.' data.navigate_to = '/attendance' break + case 'pass_slip_expired_15': + title = 'Pass slip OVERDUE' + body = 'Your pass slip has exceeded the 1-hour limit. Please return and complete it immediately.' + data.navigate_to = '/attendance' + break default: title = 'Reminder' body = 'You have a pending notification.' diff --git a/supabase/migrations/20260322150000_pass_slip_start_and_expired_notifs.sql b/supabase/migrations/20260322150000_pass_slip_start_and_expired_notifs.sql new file mode 100644 index 00000000..4ea4439f --- /dev/null +++ b/supabase/migrations/20260322150000_pass_slip_start_and_expired_notifs.sql @@ -0,0 +1,78 @@ +-- Migration: Pass slip requested_start column + expired pass slip notifications +-- +-- 1. Add optional requested_start column to pass_slips +-- 2. Create enqueue_pass_slip_expired_notifications() for recurring reminders +-- every 15 minutes after a pass slip exceeds its 1-hour limit +-- 3. Update enqueue_all_notifications() master dispatcher + +-- ============================================================================ +-- 1. SCHEMA: Add requested_start column +-- ============================================================================ +ALTER TABLE public.pass_slips + ADD COLUMN IF NOT EXISTS requested_start timestamptz; + +-- ============================================================================ +-- 2. ENQUEUE FUNCTION: Expired pass slip reminders (every 15 min after 1 hour) +-- ============================================================================ +-- Follows the end_hourly pattern: uses epoch for unique rows, checks latest +-- scheduled notification to enforce a minimum gap between reminders. +-- Caps at 24 hours to avoid processing ancient slips. +CREATE OR REPLACE FUNCTION public.enqueue_pass_slip_expired_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + rec RECORD; + v_intervals_since_expiry int; + v_latest_expired timestamptz; +BEGIN + FOR rec IN + SELECT ps.id AS pass_slip_id, ps.user_id, ps.slip_start + FROM public.pass_slips ps + WHERE ps.status = 'approved' + AND ps.slip_end IS NULL + AND ps.slip_start IS NOT NULL + -- Expired: past the 1-hour mark + AND ps.slip_start + interval '1 hour' <= now() + -- Cap at 24 hours to avoid processing ancient slips + AND ps.slip_start + interval '24 hours' > now() + LOOP + -- Calculate how many 15-min intervals since expiry (for unique epoch) + v_intervals_since_expiry := GREATEST(1, + EXTRACT(EPOCH FROM (now() - (rec.slip_start + interval '1 hour')))::int / 900 + ); + + -- Check latest expired notification for this pass slip + SELECT MAX(scheduled_for) INTO v_latest_expired + FROM public.scheduled_notifications + WHERE pass_slip_id = rec.pass_slip_id + AND user_id = rec.user_id + AND notify_type = 'pass_slip_expired_15'; + + -- Only enqueue if no prior expired reminder, or last one was >14 min ago + IF v_latest_expired IS NULL OR v_latest_expired < now() - interval '14 minutes' THEN + INSERT INTO public.scheduled_notifications + (pass_slip_id, user_id, notify_type, scheduled_for, epoch) + VALUES + (rec.pass_slip_id, rec.user_id, 'pass_slip_expired_15', now(), v_intervals_since_expiry) + ON CONFLICT DO NOTHING; + END IF; + END LOOP; +END; +$$; + +-- ============================================================================ +-- 3. MASTER DISPATCHER: Add new function +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.enqueue_all_notifications() +RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + PERFORM public.enqueue_due_shift_notifications(); + PERFORM public.enqueue_overtime_idle_notifications(); + PERFORM public.enqueue_overtime_checkout_notifications(); + PERFORM public.enqueue_isr_event_notifications(); + PERFORM public.enqueue_isr_evidence_notifications(); + PERFORM public.enqueue_paused_task_notifications(); + PERFORM public.enqueue_backlog_notifications(); + PERFORM public.enqueue_pass_slip_expiry_notifications(); + PERFORM public.enqueue_pass_slip_expired_notifications(); +END; +$$; diff --git a/supabase/migrations/20260322160000_add_swap_request_id_to_duty_schedules.sql b/supabase/migrations/20260322160000_add_swap_request_id_to_duty_schedules.sql new file mode 100644 index 00000000..4fe4c5b5 --- /dev/null +++ b/supabase/migrations/20260322160000_add_swap_request_id_to_duty_schedules.sql @@ -0,0 +1,99 @@ +-- Add swap_request_id to duty_schedules so accepted swaps are marked on the affected schedules + +ALTER TABLE public.duty_schedules + ADD COLUMN IF NOT EXISTS swap_request_id uuid REFERENCES public.swap_requests(id); + +-- Update respond_shift_swap to stamp swap_request_id on both swapped schedules +CREATE OR REPLACE FUNCTION public.respond_shift_swap(p_swap_id uuid, p_action text) +RETURNS void LANGUAGE plpgsql AS $$ +DECLARE + v_swap RECORD; +BEGIN + SELECT * INTO v_swap FROM public.swap_requests WHERE id = p_swap_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'swap request not found'; + END IF; + + IF p_action NOT IN ('accepted','rejected','admin_review') THEN + RAISE EXCEPTION 'invalid action'; + END IF; + + IF p_action = 'accepted' THEN + -- only recipient or admin/dispatcher can accept + IF NOT ( + v_swap.recipient_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher')) + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + -- ensure both shifts are still owned by the expected users before swapping + IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.shift_id AND user_id = v_swap.requester_id) THEN + RAISE EXCEPTION 'requester shift ownership changed, cannot accept swap'; + END IF; + IF v_swap.target_shift_id IS NULL THEN + RAISE EXCEPTION 'target shift missing'; + END IF; + IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.target_shift_id AND user_id = v_swap.recipient_id) THEN + RAISE EXCEPTION 'target shift ownership changed, cannot accept swap'; + END IF; + + -- perform the swap (atomic within function) and stamp swap_request_id + UPDATE public.duty_schedules + SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id + WHERE id = v_swap.shift_id; + + UPDATE public.duty_schedules + SET user_id = v_swap.requester_id, swap_request_id = p_swap_id + WHERE id = v_swap.target_shift_id; + + UPDATE public.swap_requests + SET status = 'accepted', updated_at = now() + WHERE id = p_swap_id; + + -- record approver/participant + INSERT INTO public.swap_request_participants(swap_request_id, user_id, role) + VALUES (p_swap_id, auth.uid(), 'approver') + ON CONFLICT DO NOTHING; + + -- notify requester about approval + INSERT INTO public.notifications(user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + + ELSIF p_action = 'rejected' THEN + -- only recipient or admin/dispatcher can reject + IF NOT ( + v_swap.recipient_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher')) + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + UPDATE public.swap_requests + SET status = 'rejected', updated_at = now() + WHERE id = p_swap_id; + + INSERT INTO public.swap_request_participants(swap_request_id, user_id, role) + VALUES (p_swap_id, auth.uid(), 'approver') + ON CONFLICT DO NOTHING; + + -- notify requester about rejection + INSERT INTO public.notifications(user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + + ELSE -- admin_review + -- only requester may escalate for admin review + IF NOT (v_swap.requester_id = auth.uid()) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + UPDATE public.swap_requests + SET status = 'admin_review', updated_at = now() + WHERE id = p_swap_id; + + -- notify requester about escalation + INSERT INTO public.notifications(user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + END IF; +END; +$$; diff --git a/supabase/migrations/20260322170000_fix_respond_shift_swap.sql b/supabase/migrations/20260322170000_fix_respond_shift_swap.sql new file mode 100644 index 00000000..8055db66 --- /dev/null +++ b/supabase/migrations/20260322170000_fix_respond_shift_swap.sql @@ -0,0 +1,117 @@ +-- Fix respond_shift_swap: +-- 1. Guard against double-processing (idempotent — return early if already terminal) +-- 2. SECURITY DEFINER to bypass RLS for cross-user duty_schedule ownership checks +-- (the function still enforces caller identity via auth.uid()) + +CREATE OR REPLACE FUNCTION public.respond_shift_swap(p_swap_id uuid, p_action text) +RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_swap RECORD; +BEGIN + SELECT * INTO v_swap FROM public.swap_requests WHERE id = p_swap_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'swap request not found'; + END IF; + + IF p_action NOT IN ('accepted','rejected','admin_review') THEN + RAISE EXCEPTION 'invalid action'; + END IF; + + -- Idempotency guard: already in terminal state → nothing more to do. + -- This prevents spurious "ownership changed" errors when a stale UI retries + -- an acceptance that was already processed on another device or by an admin. + IF v_swap.status IN ('accepted', 'rejected') THEN + RETURN; + END IF; + + IF p_action = 'accepted' THEN + -- only recipient or admin/dispatcher can accept + IF NOT ( + v_swap.recipient_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher')) + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + -- ensure both shifts are still owned by the expected users before swapping + IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.shift_id AND user_id = v_swap.requester_id) THEN + RAISE EXCEPTION 'requester shift ownership changed, cannot accept swap'; + END IF; + IF v_swap.target_shift_id IS NULL THEN + RAISE EXCEPTION 'target shift missing'; + END IF; + IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.target_shift_id AND user_id = v_swap.recipient_id) THEN + RAISE EXCEPTION 'target shift ownership changed, cannot accept swap'; + END IF; + + -- perform the swap (atomic within function) and stamp swap_request_id + UPDATE public.duty_schedules + SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id + WHERE id = v_swap.shift_id; + + UPDATE public.duty_schedules + SET user_id = v_swap.requester_id, swap_request_id = p_swap_id + WHERE id = v_swap.target_shift_id; + + UPDATE public.swap_requests + SET status = 'accepted', updated_at = now() + WHERE id = p_swap_id; + + INSERT INTO public.swap_request_participants(swap_request_id, user_id, role) + VALUES (p_swap_id, auth.uid(), 'approver') + ON CONFLICT DO NOTHING; + + INSERT INTO public.notifications(user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + + ELSIF p_action = 'rejected' THEN + -- only recipient or admin/dispatcher can reject + IF NOT ( + v_swap.recipient_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher')) + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + UPDATE public.swap_requests + SET status = 'rejected', updated_at = now() + WHERE id = p_swap_id; + + INSERT INTO public.swap_request_participants(swap_request_id, user_id, role) + VALUES (p_swap_id, auth.uid(), 'approver') + ON CONFLICT DO NOTHING; + + INSERT INTO public.notifications(user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + + ELSE -- admin_review + -- only requester may escalate for admin review + IF NOT (v_swap.requester_id = auth.uid()) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + UPDATE public.swap_requests + SET status = 'admin_review', updated_at = now() + WHERE id = p_swap_id; + + INSERT INTO public.notifications(user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + END IF; +END; +$$; + +-- Re-grant EXECUTE to authenticated users. +-- SECURITY DEFINER functions drop existing grants on replace, so this must +-- be explicit. +GRANT EXECUTE ON FUNCTION public.respond_shift_swap(uuid, text) TO authenticated; + +-- Enable full replica identity so UPDATE events include all columns, +-- and add to the realtime publication so .stream() receives changes. +-- Without this, the Flutter client never sees status transitions (e.g. +-- pending → accepted) via Supabase Realtime — only the initial REST fetch. +ALTER TABLE public.swap_requests REPLICA IDENTITY FULL; +DO $$ BEGIN + ALTER PUBLICATION supabase_realtime ADD TABLE public.swap_requests; +EXCEPTION WHEN duplicate_object THEN + NULL; -- already present +END $$; diff --git a/supabase/migrations/20260322180000_auto_reject_orphaned_swaps.sql b/supabase/migrations/20260322180000_auto_reject_orphaned_swaps.sql new file mode 100644 index 00000000..61a12010 --- /dev/null +++ b/supabase/migrations/20260322180000_auto_reject_orphaned_swaps.sql @@ -0,0 +1,120 @@ +-- When a swap is accepted, any OTHER pending/admin_review swap requests that +-- reference the same schedules become invalid (ownership changed). Rather than +-- letting them linger as "pending" until someone taps them and gets a confusing +-- ownership error, we automatically reject them in the same transaction. +-- +-- This fixes the "PM shift still showing after ON_CALL swap accepted" scenario: +-- Swap A: User X's ON_CALL ↔ User Y's PM → accepted +-- Swap B: User X's ON_CALL ↔ User Z's PM → auto-rejected here (stale) + +CREATE OR REPLACE FUNCTION public.respond_shift_swap(p_swap_id uuid, p_action text) +RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_swap RECORD; +BEGIN + SELECT * INTO v_swap FROM public.swap_requests WHERE id = p_swap_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'swap request not found'; + END IF; + + IF p_action NOT IN ('accepted','rejected','admin_review') THEN + RAISE EXCEPTION 'invalid action'; + END IF; + + -- Idempotency guard: already in terminal state → nothing more to do. + IF v_swap.status IN ('accepted', 'rejected') THEN + RETURN; + END IF; + + IF p_action = 'accepted' THEN + -- only recipient or admin/dispatcher can accept + IF NOT ( + v_swap.recipient_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher')) + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + -- ensure both shifts are still owned by the expected users before swapping + IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.shift_id AND user_id = v_swap.requester_id) THEN + RAISE EXCEPTION 'requester shift ownership changed, cannot accept swap'; + END IF; + IF v_swap.target_shift_id IS NULL THEN + RAISE EXCEPTION 'target shift missing'; + END IF; + IF NOT EXISTS (SELECT 1 FROM public.duty_schedules WHERE id = v_swap.target_shift_id AND user_id = v_swap.recipient_id) THEN + RAISE EXCEPTION 'target shift ownership changed, cannot accept swap'; + END IF; + + -- perform the swap (atomic within function) and stamp swap_request_id + UPDATE public.duty_schedules + SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id + WHERE id = v_swap.shift_id; + + UPDATE public.duty_schedules + SET user_id = v_swap.requester_id, swap_request_id = p_swap_id + WHERE id = v_swap.target_shift_id; + + UPDATE public.swap_requests + SET status = 'accepted', updated_at = now() + WHERE id = p_swap_id; + + -- Auto-reject all OTHER pending/admin_review swap requests that reference + -- either of the now-swapped schedules. These are stale — the shift + -- ownerships changed, so they can never be fulfilled. + UPDATE public.swap_requests + SET status = 'rejected', updated_at = now() + WHERE id <> p_swap_id + AND status IN ('pending', 'admin_review') + AND ( + shift_id = v_swap.shift_id + OR shift_id = v_swap.target_shift_id + OR target_shift_id = v_swap.shift_id + OR target_shift_id = v_swap.target_shift_id + ); + + INSERT INTO public.swap_request_participants(swap_request_id, user_id, role) + VALUES (p_swap_id, auth.uid(), 'approver') + ON CONFLICT DO NOTHING; + + INSERT INTO public.notifications(user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + + ELSIF p_action = 'rejected' THEN + -- only recipient or admin/dispatcher can reject + IF NOT ( + v_swap.recipient_id = auth.uid() + OR EXISTS (SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin','dispatcher')) + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + UPDATE public.swap_requests + SET status = 'rejected', updated_at = now() + WHERE id = p_swap_id; + + INSERT INTO public.swap_request_participants(swap_request_id, user_id, role) + VALUES (p_swap_id, auth.uid(), 'approver') + ON CONFLICT DO NOTHING; + + INSERT INTO public.notifications(user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + + ELSE -- admin_review + -- only requester may escalate for admin review + IF NOT (v_swap.requester_id = auth.uid()) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + UPDATE public.swap_requests + SET status = 'admin_review', updated_at = now() + WHERE id = p_swap_id; + + INSERT INTO public.notifications(user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + END IF; +END; +$$; + +-- Re-grant EXECUTE (SECURITY DEFINER drops grants on replace). +GRANT EXECUTE ON FUNCTION public.respond_shift_swap(uuid, text) TO authenticated; diff --git a/supabase/migrations/20260322190000_pm_oncall_companion_swap.sql b/supabase/migrations/20260322190000_pm_oncall_companion_swap.sql new file mode 100644 index 00000000..b5d29cb8 --- /dev/null +++ b/supabase/migrations/20260322190000_pm_oncall_companion_swap.sql @@ -0,0 +1,227 @@ +-- Business rule: PM Duty always comes with ON_CALL (consecutive overnight +-- shift, same person, same calendar day). Accepting a swap that involves a +-- PM schedule must also atomically transfer the companion on_call duty +-- schedule to the new PM holder so the pairing stays intact. +-- +-- Handles BOTH swap directions: +-- A) target_shift_id is PM → requester receives PM; transfer recipient's +-- companion ON_CALL to requester. +-- B) shift_id is PM → recipient receives PM; transfer requester's +-- companion ON_CALL to recipient. +-- +-- Example (direction A — Normal user initiates): +-- Before: User A owns Normal (08:00–17:00), User B owns PM (15:00–23:00) +-- and ON_CALL (23:00–07:00) on the same calendar day. +-- After accept: User A owns PM + ON_CALL, User B owns Normal. +-- +-- Example (direction B — PM user initiates): +-- Before: User B owns PM + ON_CALL, User A owns Normal. +-- After accept: same result — User A owns PM + ON_CALL, User B owns Normal. +-- +-- Supersedes 20260322180000_auto_reject_orphaned_swaps.sql. + +CREATE OR REPLACE FUNCTION public.respond_shift_swap(p_swap_id uuid, p_action text) +RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + v_swap RECORD; + v_target_type text; + v_target_date date; + v_requester_type text; + v_requester_date date; + v_companion_id uuid; +BEGIN + SELECT * INTO v_swap FROM public.swap_requests WHERE id = p_swap_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'swap request not found'; + END IF; + + IF p_action NOT IN ('accepted', 'rejected', 'admin_review') THEN + RAISE EXCEPTION 'invalid action'; + END IF; + + -- Idempotency guard: already terminal → nothing to do. + IF v_swap.status IN ('accepted', 'rejected') THEN + RETURN; + END IF; + + -- ── ACCEPTED ────────────────────────────────────────────────────────────── + IF p_action = 'accepted' THEN + + -- Permission: recipient or admin/dispatcher + IF NOT ( + v_swap.recipient_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin', 'dispatcher') + ) + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + -- Ownership validation before swapping + IF NOT EXISTS ( + SELECT 1 FROM public.duty_schedules + WHERE id = v_swap.shift_id AND user_id = v_swap.requester_id + ) THEN + RAISE EXCEPTION 'requester shift ownership changed, cannot accept swap'; + END IF; + + IF v_swap.target_shift_id IS NULL THEN + RAISE EXCEPTION 'target shift missing'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM public.duty_schedules + WHERE id = v_swap.target_shift_id AND user_id = v_swap.recipient_id + ) THEN + RAISE EXCEPTION 'target shift ownership changed, cannot accept swap'; + END IF; + + -- Read both schedules' types and dates BEFORE the swap so we can decide + -- companion direction independently of ownership changes. + SELECT shift_type, + DATE(start_time AT TIME ZONE 'Asia/Manila') + INTO v_target_type, v_target_date + FROM public.duty_schedules + WHERE id = v_swap.target_shift_id; + + SELECT shift_type, + DATE(start_time AT TIME ZONE 'Asia/Manila') + INTO v_requester_type, v_requester_date + FROM public.duty_schedules + WHERE id = v_swap.shift_id; + + -- Primary swap: transfer both schedules atomically + UPDATE public.duty_schedules + SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id + WHERE id = v_swap.shift_id; -- requester's schedule → recipient + + UPDATE public.duty_schedules + SET user_id = v_swap.requester_id, swap_request_id = p_swap_id + WHERE id = v_swap.target_shift_id; -- recipient's schedule → requester + + -- ── Companion ON_CALL transfer ──────────────────────────────────────── + -- Direction A: target is PM → requester is the new PM holder. + -- Find companion ON_CALL previously owned by the RECIPIENT and give it + -- to the REQUESTER. + IF v_target_type = 'pm' THEN + SELECT id INTO v_companion_id + FROM public.duty_schedules + WHERE user_id = v_swap.recipient_id + AND shift_type = 'on_call' + AND DATE(start_time AT TIME ZONE 'Asia/Manila') = v_target_date + ORDER BY start_time + LIMIT 1; + + IF v_companion_id IS NOT NULL THEN + UPDATE public.duty_schedules + SET user_id = v_swap.requester_id, swap_request_id = p_swap_id + WHERE id = v_companion_id; + + UPDATE public.swap_requests + SET status = 'rejected', updated_at = now() + WHERE id <> p_swap_id + AND status IN ('pending', 'admin_review') + AND ( + shift_id = v_companion_id + OR target_shift_id = v_companion_id + ); + END IF; + + -- Direction B: requester's schedule is PM → recipient is the new PM holder. + -- Find companion ON_CALL previously owned by the REQUESTER and give it + -- to the RECIPIENT. + ELSIF v_requester_type = 'pm' THEN + SELECT id INTO v_companion_id + FROM public.duty_schedules + WHERE user_id = v_swap.requester_id + AND shift_type = 'on_call' + AND DATE(start_time AT TIME ZONE 'Asia/Manila') = v_requester_date + ORDER BY start_time + LIMIT 1; + + IF v_companion_id IS NOT NULL THEN + UPDATE public.duty_schedules + SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id + WHERE id = v_companion_id; + + UPDATE public.swap_requests + SET status = 'rejected', updated_at = now() + WHERE id <> p_swap_id + AND status IN ('pending', 'admin_review') + AND ( + shift_id = v_companion_id + OR target_shift_id = v_companion_id + ); + END IF; + END IF; + -- ───────────────────────────────────────────────────────────────────── + + -- Accept the swap request + UPDATE public.swap_requests + SET status = 'accepted', updated_at = now() + WHERE id = p_swap_id; + + -- Auto-reject all other pending swaps referencing either primary schedule + UPDATE public.swap_requests + SET status = 'rejected', updated_at = now() + WHERE id <> p_swap_id + AND status IN ('pending', 'admin_review') + AND ( + shift_id = v_swap.shift_id + OR shift_id = v_swap.target_shift_id + OR target_shift_id = v_swap.shift_id + OR target_shift_id = v_swap.target_shift_id + ); + + INSERT INTO public.swap_request_participants (swap_request_id, user_id, role) + VALUES (p_swap_id, auth.uid(), 'approver') + ON CONFLICT DO NOTHING; + + INSERT INTO public.notifications (user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + + -- ── REJECTED ────────────────────────────────────────────────────────────── + ELSIF p_action = 'rejected' THEN + + IF NOT ( + v_swap.recipient_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin', 'dispatcher') + ) + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + UPDATE public.swap_requests + SET status = 'rejected', updated_at = now() + WHERE id = p_swap_id; + + INSERT INTO public.swap_request_participants (swap_request_id, user_id, role) + VALUES (p_swap_id, auth.uid(), 'approver') + ON CONFLICT DO NOTHING; + + INSERT INTO public.notifications (user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + + -- ── ADMIN_REVIEW ────────────────────────────────────────────────────────── + ELSE + IF NOT (v_swap.requester_id = auth.uid()) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + UPDATE public.swap_requests + SET status = 'admin_review', updated_at = now() + WHERE id = p_swap_id; + + INSERT INTO public.notifications (user_id, actor_id, type, created_at) + VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); + END IF; +END; +$$; + +-- Re-grant after SECURITY DEFINER replace +GRANT EXECUTE ON FUNCTION public.respond_shift_swap(uuid, text) TO authenticated; diff --git a/supabase/migrations/20260322200000_duty_schedules_realtime.sql b/supabase/migrations/20260322200000_duty_schedules_realtime.sql new file mode 100644 index 00000000..568217cc --- /dev/null +++ b/supabase/migrations/20260322200000_duty_schedules_realtime.sql @@ -0,0 +1,10 @@ +-- Enable realtime updates for duty_schedules so that schedule ownership +-- changes (e.g. after a swap is accepted) propagate to all clients +-- immediately without waiting for the next poll cycle. +ALTER TABLE public.duty_schedules REPLICA IDENTITY FULL; + +DO $$ BEGIN + ALTER PUBLICATION supabase_realtime ADD TABLE public.duty_schedules; +EXCEPTION WHEN duplicate_object THEN + NULL; +END $$;