Added My Schedule tab in attendance screen
Allow 1 Day, Whole Week and Date Range swapping
This commit is contained in:
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(
|
||||
|
||||
Reference in New Issue
Block a user