Swap Accept, Reject and Escalate
This commit is contained in:
@@ -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?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user