Added My Schedule tab in attendance screen

Allow 1 Day, Whole Week and Date Range swapping
This commit is contained in:
Marc Rejohn Castillano 2026-03-22 11:52:25 +08:00
parent ba155885c0
commit 049ab2c794
17 changed files with 2515 additions and 305 deletions

View File

@ -12,6 +12,7 @@ class DutySchedule {
required this.checkInAt, required this.checkInAt,
required this.checkInLocation, required this.checkInLocation,
required this.relieverIds, required this.relieverIds,
this.swapRequestId,
}); });
final String id; final String id;
@ -24,6 +25,7 @@ class DutySchedule {
final DateTime? checkInAt; final DateTime? checkInAt;
final Object? checkInLocation; final Object? checkInLocation;
final List<String> relieverIds; final List<String> relieverIds;
final String? swapRequestId;
factory DutySchedule.fromMap(Map<String, dynamic> map) { factory DutySchedule.fromMap(Map<String, dynamic> map) {
final relieversRaw = map['reliever_ids']; final relieversRaw = map['reliever_ids'];
@ -47,6 +49,7 @@ class DutySchedule {
: AppTime.parse(map['check_in_at'] as String), : AppTime.parse(map['check_in_at'] as String),
checkInLocation: map['check_in_location'], checkInLocation: map['check_in_location'],
relieverIds: relievers, relieverIds: relievers,
swapRequestId: map['swap_request_id'] as String?,
); );
} }
} }

View File

@ -12,6 +12,7 @@ class PassSlip {
this.approvedAt, this.approvedAt,
this.slipStart, this.slipStart,
this.slipEnd, this.slipEnd,
this.requestedStart,
}); });
final String id; final String id;
@ -24,6 +25,7 @@ class PassSlip {
final DateTime? approvedAt; final DateTime? approvedAt;
final DateTime? slipStart; final DateTime? slipStart;
final DateTime? slipEnd; final DateTime? slipEnd;
final DateTime? requestedStart;
/// Whether the slip is active (approved but not yet completed). /// Whether the slip is active (approved but not yet completed).
bool get isActive => status == 'approved' && slipEnd == null; bool get isActive => status == 'approved' && slipEnd == null;
@ -52,6 +54,9 @@ class PassSlip {
slipEnd: map['slip_end'] == null slipEnd: map['slip_end'] == null
? null ? null
: AppTime.parse(map['slip_end'] as String), : AppTime.parse(map['slip_end'] as String),
requestedStart: map['requested_start'] == null
? null
: AppTime.parse(map['requested_start'] as String),
); );
} }
} }

View File

@ -78,6 +78,7 @@ class PassSlipController {
Future<void> requestSlip({ Future<void> requestSlip({
required String dutyScheduleId, required String dutyScheduleId,
required String reason, required String reason,
DateTime? requestedStart,
}) async { }) async {
final userId = _client.auth.currentUser?.id; final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated'); if (userId == null) throw Exception('Not authenticated');
@ -87,6 +88,8 @@ class PassSlipController {
'reason': reason, 'reason': reason,
'status': 'pending', 'status': 'pending',
'requested_at': DateTime.now().toUtc().toIso8601String(), 'requested_at': DateTime.now().toUtc().toIso8601String(),
if (requestedStart != null)
'requested_start': requestedStart.toUtc().toIso8601String(),
}; };
final insertedRaw = await _client final insertedRaw = await _client
@ -174,13 +177,29 @@ class PassSlipController {
final userId = _client.auth.currentUser?.id; final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated'); 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 await _client
.from('pass_slips') .from('pass_slips')
.update({ .update({
'status': 'approved', 'status': 'approved',
'approved_by': userId, 'approved_by': userId,
'approved_at': DateTime.now().toUtc().toIso8601String(), 'approved_at': nowUtc.toIso8601String(),
'slip_start': DateTime.now().toUtc().toIso8601String(), 'slip_start': slipStartIso,
}) })
.eq('id', slipId); .eq('id', slipId);

View File

@ -344,6 +344,11 @@ class StreamRecoveryWrapper<T> {
} }
} }
/// 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<void> pollNow() async => _pollOnce();
/// Manually trigger a recovery attempt. /// Manually trigger a recovery attempt.
void retry() { void retry() {
_recoveryAttempts = 0; _recoveryAttempts = 0;

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
@ -53,6 +55,20 @@ final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
); );
ref.onDispose(wrapper.dispose); 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); return wrapper.stream.map((result) => result.data);
}); });
@ -127,6 +143,7 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
final data = await client final data = await client
.from('swap_requests') .from('swap_requests')
.select() .select()
.inFilter('status', ['pending', 'admin_review'])
.order('created_at', ascending: false); .order('created_at', ascending: false);
return data.map(SwapRequest.fromMap).toList(); return data.map(SwapRequest.fromMap).toList();
}, },
@ -136,13 +153,28 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
); );
ref.onDispose(wrapper.dispose); 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) { return wrapper.stream.map((result) {
// only return requests that are still actionable; once a swap has been // 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 // 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 // either party. admins still see "admin_review" rows so they can act on
// escalated cases. // escalated cases.
return result.data.where((row) { 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; return false;
} }
// only keep pending and admin_review statuses // only keep pending and admin_review statuses
@ -151,6 +183,20 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((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<Set<String>>((ref) => {});
/// IDs of duty_schedules owned by the current user that were created by an accepted swap.
final swappedScheduleIdsProvider = Provider<Set<String>>((ref) {
final schedules = ref.watch(dutySchedulesProvider).valueOrNull ?? [];
return {
for (final s in schedules)
if (s.swapRequestId != null) s.id,
};
});
final workforceControllerProvider = Provider<WorkforceController>((ref) { final workforceControllerProvider = Provider<WorkforceController>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return WorkforceController(client); return WorkforceController(client);

View File

@ -34,6 +34,17 @@ import '../theme/m3_motion.dart';
import '../utils/navigation.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<GoRouter>((ref) { final appRouterProvider = Provider<GoRouter>((ref) {
final notifier = RouterNotifier(ref); final notifier = RouterNotifier(ref);
ref.onDispose(notifier.dispose); ref.onDispose(notifier.dispose);
@ -71,7 +82,19 @@ final appRouterProvider = Provider<GoRouter>((ref) {
return '/login'; return '/login';
} }
if (isSignedIn && isAuthRoute) { 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) { if (isAdminRoute && !isAdmin) {
return '/tickets'; return '/tickets';
@ -274,8 +297,9 @@ class RouterNotifier extends ChangeNotifier {
? previous.value?.session ? previous.value?.session
: null; : null;
if (session != null && previousSession == 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(); _enforceLockAsync();
_needsRoleRedirect = true;
} }
} }
notifyListeners(); notifyListeners();
@ -290,6 +314,10 @@ class RouterNotifier extends ChangeNotifier {
late final ProviderSubscription _profileSub; late final ProviderSubscription _profileSub;
bool _lockEnforcementInProgress = false; 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 /// Safely enforce lock in the background, preventing concurrent calls
void _enforceLockAsync() { void _enforceLockAsync() {
// Prevent concurrent enforcement calls // Prevent concurrent enforcement calls

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ import '../../providers/rotation_config_provider.dart';
import '../../providers/workforce_provider.dart'; import '../../providers/workforce_provider.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
import '../../providers/ramadan_provider.dart'; import '../../providers/ramadan_provider.dart';
import '../../providers/notifications_provider.dart';
import '../../widgets/app_page_header.dart'; import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart'; import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
@ -49,6 +50,9 @@ class WorkforceScreen extends ConsumerWidget {
); );
if (isWide) { if (isWide) {
if (!isAdmin) {
return schedulePanel;
}
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -58,8 +62,8 @@ class WorkforceScreen extends ConsumerWidget {
flex: 2, flex: 2,
child: Column( child: Column(
children: [ children: [
if (isAdmin) generatorPanel, generatorPanel,
if (isAdmin) const SizedBox(height: 16), const SizedBox(height: 16),
Expanded(child: swapsPanel), Expanded(child: swapsPanel),
], ],
), ),
@ -68,16 +72,19 @@ class WorkforceScreen extends ConsumerWidget {
); );
} }
if (!isAdmin) {
return schedulePanel;
}
return DefaultTabController( return DefaultTabController(
length: isAdmin ? 3 : 2, length: 3,
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
TabBar( const TabBar(
tabs: [ tabs: [
const Tab(text: 'Schedule'), Tab(text: 'Schedule'),
const Tab(text: 'Swaps'), Tab(text: 'Swaps'),
if (isAdmin) const Tab(text: 'Generator'), Tab(text: 'Generator'),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@ -86,7 +93,7 @@ class WorkforceScreen extends ConsumerWidget {
children: [ children: [
schedulePanel, schedulePanel,
swapsPanel, swapsPanel,
if (isAdmin) generatorPanel, generatorPanel,
], ],
), ),
), ),
@ -314,23 +321,6 @@ class _ScheduleTile extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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; final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
ShiftTypeConfig? shiftTypeConfig; ShiftTypeConfig? shiftTypeConfig;
@ -442,16 +432,6 @@ class _ScheduleTile extends ConsumerWidget {
onPressed: () => _editSchedule(context, ref), onPressed: () => _editSchedule(context, ref),
icon: const Icon(Icons.edit, size: 20), 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<void> _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<DutySchedule> recipientShifts = [];
String? selectedTargetShiftId;
final confirmed = await m3ShowDialog<bool>(
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<String>(
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((_) => <DutySchedule>[]);
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<String>(
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) { String _statusLabel(String status) {
switch (status) { switch (status) {
@ -2347,6 +2135,7 @@ class _SwapRequestsPanel extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final swapsAsync = ref.watch(swapRequestsProvider); final swapsAsync = ref.watch(swapRequestsProvider);
final removedSwapIds = ref.watch(locallyRemovedSwapIdsProvider);
final schedulesAsync = ref.watch(dutySchedulesProvider); final schedulesAsync = ref.watch(dutySchedulesProvider);
final profilesAsync = ref.watch(profilesProvider); final profilesAsync = ref.watch(profilesProvider);
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull; final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
@ -2362,7 +2151,12 @@ class _SwapRequestsPanel extends ConsumerWidget {
}; };
return swapsAsync.when( 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) { if (items.isEmpty) {
return const Center(child: Text('No swap requests.')); return const Center(child: Text('No swap requests.'));
} }
@ -2534,7 +2328,31 @@ class _SwapRequestsPanel extends ConsumerWidget {
await ref await ref
.read(workforceControllerProvider) .read(workforceControllerProvider)
.respondSwap(swapId: request.id, action: action); .respondSwap(swapId: request.id, action: action);
ref.read(locallyRemovedSwapIdsProvider.notifier).update((s) => {...s, request.id});
ref.invalidate(swapRequestsProvider); 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<void> _changeRecipient( Future<void> _changeRecipient(

View File

@ -61,6 +61,21 @@ class AppTime {
/// Renders a [DateTime] in 12hour clock notation with AM/PM suffix. /// Renders a [DateTime] in 12hour clock notation with AM/PM suffix.
/// ///
/// Example: **08:30 PM**. Used primarily in workforce-related screens. /// 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) { static String formatTime(DateTime value) {
final rawHour = value.hour; final rawHour = value.hour;
final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft( final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft(

View File

@ -104,20 +104,37 @@ class _PassSlipCountdownBannerState
return widget.child; return widget.child;
} }
final isUrgent = !_exceeded && _remaining.inMinutes < 5; final now = DateTime.now();
final bgColor = _exceeded || isUrgent final hasStarted = now.isAfter(activeSlip.slipStart!) ||
? Theme.of(context).colorScheme.errorContainer now.isAtSameMomentAs(activeSlip.slipStart!);
: Theme.of(context).colorScheme.tertiaryContainer;
final fgColor = _exceeded || isUrgent
? Theme.of(context).colorScheme.onErrorContainer
: Theme.of(context).colorScheme.onTertiaryContainer;
final bool isUrgent;
final Color bgColor;
final Color fgColor;
final String message; final String message;
final IconData icon; final IconData icon;
if (_exceeded) { 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'; message = 'Pass slip time EXCEEDED — Please return and complete it';
icon = Icons.warning_amber_rounded; 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 { } 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)}'; message = 'Pass slip expires in ${_formatDuration(_remaining)}';
icon = Icons.directions_walk_rounded; icon = Icons.directions_walk_rounded;
} }

View File

@ -143,6 +143,11 @@ async function processBatch() {
body = 'Your pass slip expires in 15 minutes. Please return and complete it.' body = 'Your pass slip expires in 15 minutes. Please return and complete it.'
data.navigate_to = '/attendance' data.navigate_to = '/attendance'
break 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: default:
title = 'Reminder' title = 'Reminder'
body = 'You have a pending notification.' body = 'You have a pending notification.'

View File

@ -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;
$$;

View File

@ -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;
$$;

View File

@ -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 $$;

View File

@ -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;

View File

@ -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:0017:00), User B owns PM (15:0023:00)
-- and ON_CALL (23:0007: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;

View File

@ -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 $$;