Swap Accept, Reject and Escalate
This commit is contained in:
@@ -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