Added My Schedule tab in attendance screen
Allow 1 Day, Whole Week and Date Range swapping
This commit is contained in:
parent
ba155885c0
commit
049ab2c794
|
|
@ -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<String> relieverIds;
|
||||
final String? swapRequestId;
|
||||
|
||||
factory DutySchedule.fromMap(Map<String, dynamic> 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ class PassSlipController {
|
|||
Future<void> 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
void retry() {
|
||||
_recoveryAttempts = 0;
|
||||
|
|
|
|||
|
|
@ -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<List<DutySchedule>>((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<List<SwapRequest>>((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<List<SwapRequest>>((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<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 client = ref.watch(supabaseClientProvider);
|
||||
return WorkforceController(client);
|
||||
|
|
|
|||
|
|
@ -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<GoRouter>((ref) {
|
||||
final notifier = RouterNotifier(ref);
|
||||
ref.onDispose(notifier.dispose);
|
||||
|
|
@ -71,7 +82,19 @@ final appRouterProvider = Provider<GoRouter>((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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<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) {
|
||||
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<void> _changeRecipient(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
$$;
|
||||
|
|
@ -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;
|
||||
$$;
|
||||
117
supabase/migrations/20260322170000_fix_respond_shift_swap.sql
Normal file
117
supabase/migrations/20260322170000_fix_respond_shift_swap.sql
Normal 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 $$;
|
||||
|
|
@ -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;
|
||||
227
supabase/migrations/20260322190000_pm_oncall_companion_swap.sql
Normal file
227
supabase/migrations/20260322190000_pm_oncall_companion_swap.sql
Normal 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: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;
|
||||
|
|
@ -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 $$;
|
||||
Loading…
Reference in New Issue
Block a user