Swap Accept, Reject and Escalate

This commit is contained in:
2026-02-21 08:31:20 +08:00
parent c64c356c1b
commit 4811621dc5
9 changed files with 1092 additions and 179 deletions
+28 -4
View File
@@ -5,20 +5,30 @@ class SwapRequest {
required this.id,
required this.requesterId,
required this.recipientId,
required this.shiftId,
required this.requesterScheduleId,
required this.targetScheduleId,
required this.status,
required this.createdAt,
required this.updatedAt,
required this.approvedBy,
this.chatThreadId,
this.shiftType,
this.shiftStartTime,
this.relieverIds,
this.approvedBy,
});
final String id;
final String requesterId;
final String recipientId;
final String shiftId;
final String requesterScheduleId; // previously `shiftId`
final String? targetScheduleId;
final String status;
final DateTime createdAt;
final DateTime? updatedAt;
final String? chatThreadId;
final String? shiftType;
final DateTime? shiftStartTime;
final List<String>? relieverIds;
final String? approvedBy;
factory SwapRequest.fromMap(Map<String, dynamic> map) {
@@ -26,12 +36,26 @@ class SwapRequest {
id: map['id'] as String,
requesterId: map['requester_id'] as String,
recipientId: map['recipient_id'] as String,
shiftId: map['shift_id'] as String,
requesterScheduleId:
(map['requester_schedule_id'] as String?) ??
(map['shift_id'] as String),
targetScheduleId: map['target_shift_id'] as String?,
status: map['status'] as String? ?? 'pending',
createdAt: AppTime.parse(map['created_at'] as String),
updatedAt: map['updated_at'] == null
? null
: AppTime.parse(map['updated_at'] as String),
chatThreadId: map['chat_thread_id'] as String?,
shiftType: map['shift_type'] as String?,
shiftStartTime: map['shift_start_time'] == null
? null
: AppTime.parse(map['shift_start_time'] as String),
relieverIds: map['reliever_ids'] is List
? (map['reliever_ids'] as List)
.where((e) => e != null)
.map((e) => e.toString())
.toList()
: const <String>[],
approvedBy: map['approved_by'] as String?,
);
}
+63 -2
View File
@@ -40,6 +40,45 @@ final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
.map((rows) => rows.map(DutySchedule.fromMap).toList());
});
/// Fetch duty schedules by a list of IDs (used by UI when swap requests reference
/// schedules that are not included in the current user's `dutySchedulesProvider`).
final dutySchedulesByIdsProvider =
FutureProvider.family<List<DutySchedule>, List<String>>((ref, ids) async {
if (ids.isEmpty) return const <DutySchedule>[];
final client = ref.watch(supabaseClientProvider);
final quoted = ids.map((id) => '"$id"').join(',');
final inList = '($quoted)';
final rows =
await client
.from('duty_schedules')
.select()
.filter('id', 'in', inList)
as List<dynamic>;
return rows
.map((r) => DutySchedule.fromMap(r as Map<String, dynamic>))
.toList();
});
/// Fetch upcoming duty schedules for a specific user (used by swap UI to
/// let the requester pick a concrete target shift owned by the recipient).
final dutySchedulesForUserProvider =
FutureProvider.family<List<DutySchedule>, String>((ref, userId) async {
final client = ref.watch(supabaseClientProvider);
final nowIso = DateTime.now().toUtc().toIso8601String();
final rows =
await client
.from('duty_schedules')
.select()
.eq('user_id', userId)
/* exclude past schedules by ensuring the shift has not ended */
.gte('end_time', nowIso)
.order('start_time')
as List<dynamic>;
return rows
.map((r) => DutySchedule.fromMap(r as Map<String, dynamic>))
.toList();
});
final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
@@ -110,12 +149,17 @@ class WorkforceController {
}
Future<String?> requestSwap({
required String shiftId,
required String requesterScheduleId,
required String targetScheduleId,
required String recipientId,
}) async {
final data = await _client.rpc(
'request_shift_swap',
params: {'p_shift_id': shiftId, 'p_recipient_id': recipientId},
params: {
'p_shift_id': requesterScheduleId,
'p_target_shift_id': targetScheduleId,
'p_recipient_id': recipientId,
},
);
return data as String?;
}
@@ -130,6 +174,23 @@ class WorkforceController {
);
}
/// Reassign the recipient of a swap request. Only admins/dispatchers are
/// expected to call this; the DB RLS and RPCs will additionally enforce rules.
Future<void> reassignSwap({
required String swapId,
required String newRecipientId,
}) async {
// Prefer using an RPC for server-side validation, but update directly here
await _client
.from('swap_requests')
.update({
'recipient_id': newRecipientId,
'status': 'pending',
'updated_at': DateTime.now().toUtc().toIso8601String(),
})
.eq('id', swapId);
}
String _formatDate(DateTime value) {
final date = DateTime(value.year, value.month, value.day);
final month = date.month.toString().padLeft(2, '0');
@@ -145,6 +145,10 @@ class NotificationsScreen extends ConsumerWidget {
return '$actorName assigned you';
case 'created':
return '$actorName created a new item';
case 'swap_request':
return '$actorName requested a shift swap';
case 'swap_update':
return '$actorName updated a swap request';
case 'mention':
default:
return '$actorName mentioned you';
@@ -157,6 +161,10 @@ class NotificationsScreen extends ConsumerWidget {
return Icons.assignment_ind_outlined;
case 'created':
return Icons.campaign_outlined;
case 'swap_request':
return Icons.swap_horiz;
case 'swap_update':
return Icons.update;
case 'mention':
default:
return Icons.alternate_email;
+243 -173
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tasq/utils/app_time.dart';
import 'package:geolocator/geolocator.dart';
import 'package:timezone/timezone.dart' as tz;
@@ -10,7 +11,6 @@ import '../../models/profile.dart';
import '../../models/swap_request.dart';
import '../../providers/profile_provider.dart';
import '../../providers/workforce_provider.dart';
import '../../utils/app_time.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart';
@@ -128,7 +128,7 @@ class _SchedulePanel extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_formatDay(day),
AppTime.formatDate(day),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
@@ -193,40 +193,6 @@ class _SchedulePanel extends ConsumerWidget {
}
}
String _formatDay(DateTime value) {
return _formatFullDate(value);
}
String _formatFullDate(DateTime value) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const weekdays = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
];
final month = months[value.month - 1];
final day = value.day.toString().padLeft(2, '0');
final weekday = weekdays[value.weekday - 1];
return '$weekday, $month $day, ${value.year}';
}
List<String> _relieverLabelsFromIds(
List<String> relieverIds,
Map<String, Profile> profileById,
@@ -269,7 +235,7 @@ class _ScheduleTile extends ConsumerWidget {
now.isBefore(schedule.endTime);
final hasRequestedSwap = swaps.any(
(swap) =>
swap.shiftId == schedule.id &&
swap.requesterScheduleId == schedule.id &&
swap.requesterId == currentUserId &&
swap.status == 'pending',
);
@@ -294,7 +260,7 @@ class _ScheduleTile extends ConsumerWidget {
),
const SizedBox(height: 4),
Text(
'${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}',
'${AppTime.formatTime(schedule.startTime)} - ${AppTime.formatTime(schedule.endTime)}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),
@@ -477,48 +443,130 @@ class _ScheduleTile extends ConsumerWidget {
}
String? selectedId = staff.first.id;
List<DutySchedule> recipientShifts = [];
String? selectedTargetShiftId;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Request swap'),
content: DropdownButtonFormField<String>(
initialValue: selectedId,
items: [
for (final profile in staff)
DropdownMenuItem(
value: profile.id,
child: Text(
profile.fullName.isNotEmpty ? profile.fullName : profile.id,
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>(
value: 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>(
value: 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'),
),
],
onChanged: (value) => selectedId = value,
decoration: const InputDecoration(labelText: 'Recipient'),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Send request'),
),
],
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Send request'),
),
],
);
},
);
},
);
if (!context.mounted) return;
if (confirmed != true || selectedId == null) return;
if (confirmed != true ||
selectedId == null ||
selectedTargetShiftId == null)
return;
try {
await ref
.read(workforceControllerProvider)
.requestSwap(shiftId: schedule.id, recipientId: selectedId!);
.requestSwap(
requesterScheduleId: schedule.id,
targetScheduleId: selectedTargetShiftId!,
recipientId: selectedId!,
);
ref.invalidate(swapRequestsProvider);
if (!context.mounted) return;
_showMessage(context, 'Swap request sent.');
@@ -599,17 +647,6 @@ class _ScheduleTile extends ConsumerWidget {
_showMessage(context, 'Swap request already sent. See Swaps panel.');
}
String _formatTime(DateTime value) {
final rawHour = value.hour;
final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft(
2,
'0',
);
final minute = value.minute.toString().padLeft(2, '0');
final suffix = rawHour >= 12 ? 'PM' : 'AM';
return '$hour:$minute $suffix';
}
String _statusLabel(String status) {
switch (status) {
case 'arrival':
@@ -816,7 +853,7 @@ class _ScheduleGeneratorPanelState
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(labelText: label),
child: Text(value == null ? 'Select date' : _formatDate(value)),
child: Text(value == null ? 'Select date' : AppTime.formatDate(value)),
),
);
}
@@ -938,8 +975,8 @@ class _ScheduleGeneratorPanelState
}
Widget _buildDraftHeader(BuildContext context) {
final start = _startDate == null ? '' : _formatDate(_startDate!);
final end = _endDate == null ? '' : _formatDate(_endDate!);
final start = _startDate == null ? '' : AppTime.formatDate(_startDate!);
final end = _endDate == null ? '' : AppTime.formatDate(_endDate!);
return Row(
children: [
Text(
@@ -992,7 +1029,7 @@ class _ScheduleGeneratorPanelState
),
const SizedBox(height: 4),
Text(
'${_formatDate(draft.startTime)} · ${_formatTime(draft.startTime)} - ${_formatTime(draft.endTime)}',
'${AppTime.formatDate(draft.startTime)} · ${AppTime.formatTime(draft.startTime)} - ${AppTime.formatTime(draft.endTime)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -1124,7 +1161,7 @@ class _ScheduleGeneratorPanelState
const SizedBox(height: 12),
_dialogDateField(
label: 'Date',
value: _formatDate(selectedDate),
value: AppTime.formatDate(selectedDate),
onTap: () async {
final picked = await showDatePicker(
context: context,
@@ -1640,7 +1677,7 @@ class _ScheduleGeneratorPanelState
drafts.where((d) => d.localId != draft.localId).toList(),
existing,
)) {
return 'Conflict found for ${_formatDate(draft.startTime)}.';
return 'Conflict found for ${AppTime.formatDate(draft.startTime)}.';
}
}
return null;
@@ -1686,7 +1723,9 @@ class _ScheduleGeneratorPanelState
for (final shift in required) {
if (!available.contains(shift)) {
warnings.add('${_formatDate(day)} missing ${_shiftLabel(shift)}');
warnings.add(
'${AppTime.formatDate(day)} missing ${_shiftLabel(shift)}',
);
}
}
@@ -1726,47 +1765,6 @@ class _ScheduleGeneratorPanelState
context,
).showSnackBar(SnackBar(content: Text(message)));
}
String _formatTime(DateTime value) {
final rawHour = value.hour;
final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft(
2,
'0',
);
final minute = value.minute.toString().padLeft(2, '0');
final suffix = rawHour >= 12 ? 'PM' : 'AM';
return '$hour:$minute $suffix';
}
String _formatDate(DateTime value) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const weekdays = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
];
final month = months[value.month - 1];
final day = value.day.toString().padLeft(2, '0');
final weekday = weekdays[value.weekday - 1];
return '$weekday, $month $day, ${value.year}';
}
}
class _SwapRequestsPanel extends ConsumerWidget {
@@ -1795,13 +1793,38 @@ class _SwapRequestsPanel extends ConsumerWidget {
if (items.isEmpty) {
return const Center(child: Text('No swap requests.'));
}
// If a swap references schedules that aren't in the current
// `dutySchedulesProvider` (for example the requester owns the shift),
// fetch those schedules by id so we can render shift details instead of
// "Shift not found".
final missingIds = items
.expand(
(s) => [
s.requesterScheduleId,
if (s.targetScheduleId != null) s.targetScheduleId!,
],
)
.where((id) => !scheduleById.containsKey(id))
.toSet()
.toList();
final missingSchedules =
ref.watch(dutySchedulesByIdsProvider(missingIds)).valueOrNull ?? [];
for (final s in missingSchedules) {
scheduleById[s.id] = s;
}
return ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final item = items[index];
final schedule = scheduleById[item.shiftId];
final requesterSchedule = scheduleById[item.requesterScheduleId];
final targetSchedule = item.targetScheduleId != null
? scheduleById[item.targetScheduleId!]
: null;
final requesterProfile = profileById[item.requesterId];
final recipientProfile = profileById[item.recipientId];
final requester = requesterProfile?.fullName.isNotEmpty == true
@@ -1810,16 +1833,39 @@ class _SwapRequestsPanel extends ConsumerWidget {
final recipient = recipientProfile?.fullName.isNotEmpty == true
? recipientProfile!.fullName
: item.recipientId;
final subtitle = schedule == null
? 'Shift not found'
: '${_shiftLabel(schedule.shiftType)} · ${_formatDate(schedule.startTime)} · ${_formatTime(schedule.startTime)}';
final relieverLabels = schedule == null
? const <String>[]
: _relieverLabelsFromIds(schedule.relieverIds, profileById);
String subtitle;
if (requesterSchedule != null && targetSchedule != null) {
subtitle =
'${_shiftLabel(requesterSchedule.shiftType)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)}${_shiftLabel(targetSchedule.shiftType)} · ${AppTime.formatDate(targetSchedule.startTime)} · ${AppTime.formatTime(targetSchedule.startTime)}';
} else if (requesterSchedule != null) {
subtitle =
'${_shiftLabel(requesterSchedule.shiftType)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)}';
} else if (item.shiftStartTime != null) {
subtitle =
'${_shiftLabel(item.shiftType ?? 'normal')} · ${AppTime.formatDate(item.shiftStartTime!)} · ${AppTime.formatTime(item.shiftStartTime!)}';
} else {
subtitle = 'Shift not found';
}
final relieverLabels = requesterSchedule != null
? _relieverLabelsFromIds(
requesterSchedule.relieverIds,
profileById,
)
: (item.relieverIds?.isNotEmpty == true
? item.relieverIds!
.map((id) => profileById[id]?.fullName ?? id)
.toList()
: const <String>[]);
final isPending = item.status == 'pending';
// Admins may act on regular pending swaps and also on escalated
// swaps (status == 'admin_review'). Standard recipients can only
// act when the swap is pending.
final canRespond =
(isAdmin || item.recipientId == currentUserId) && isPending;
(isPending && (isAdmin || item.recipientId == currentUserId)) ||
(isAdmin && item.status == 'admin_review');
final canEscalate = item.requesterId == currentUserId && isPending;
return Card(
@@ -1871,6 +1917,14 @@ class _SwapRequestsPanel extends ConsumerWidget {
child: const Text('Reject'),
),
],
if (isAdmin && item.status == 'admin_review') ...[
const SizedBox(width: 8),
OutlinedButton(
onPressed: () =>
_changeRecipient(context, ref, item),
child: const Text('Change recipient'),
),
],
if (canEscalate) ...[
const SizedBox(width: 8),
OutlinedButton(
@@ -1904,6 +1958,63 @@ class _SwapRequestsPanel extends ConsumerWidget {
ref.invalidate(swapRequestsProvider);
}
Future<void> _changeRecipient(
BuildContext context,
WidgetRef ref,
SwapRequest request,
) async {
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
final eligible = profiles
.where(
(p) => p.id != request.requesterId && p.id != request.recipientId,
)
.toList();
if (eligible.isEmpty) {
// nothing to choose from
return;
}
Profile? _choice = eligible.first;
final selected = await showDialog<Profile?>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Change recipient'),
content: StatefulBuilder(
builder: (context, setState) => DropdownButtonFormField<Profile>(
value: _choice,
items: eligible
.map(
(p) => DropdownMenuItem(value: p, child: Text(p.fullName)),
)
.toList(),
onChanged: (v) => setState(() => _choice = v),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_choice),
child: const Text('Save'),
),
],
);
},
);
if (selected == null) return;
await ref
.read(workforceControllerProvider)
.reassignSwap(swapId: request.id, newRecipientId: selected.id);
ref.invalidate(swapRequestsProvider);
}
String _shiftLabel(String value) {
switch (value) {
case 'am':
@@ -1921,47 +2032,6 @@ class _SwapRequestsPanel extends ConsumerWidget {
}
}
String _formatTime(DateTime value) {
final rawHour = value.hour;
final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft(
2,
'0',
);
final minute = value.minute.toString().padLeft(2, '0');
final suffix = rawHour >= 12 ? 'PM' : 'AM';
return '$hour:$minute $suffix';
}
String _formatDate(DateTime value) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const weekdays = [
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
];
final month = months[value.month - 1];
final day = value.day.toString().padLeft(2, '0');
final weekday = weekdays[value.weekday - 1];
return '$weekday, $month $day, ${value.year}';
}
List<String> _relieverLabelsFromIds(
List<String> relieverIds,
Map<String, Profile> profileById,