tasq/lib/screens/workforce/workforce_screen.dart

2508 lines
81 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tasq/utils/app_time.dart';
import 'package:timezone/timezone.dart' as tz;
import '../../models/duty_schedule.dart';
import '../../models/profile.dart';
import '../../models/rotation_config.dart';
import '../../models/swap_request.dart';
import '../../providers/profile_provider.dart';
import '../../providers/rotation_config_provider.dart';
import '../../providers/workforce_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/ramadan_provider.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart';
import 'rotation_settings_dialog.dart';
class WorkforceScreen extends ConsumerWidget {
const WorkforceScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(currentProfileProvider);
final role = profileAsync.valueOrNull?.role ?? 'standard';
final isAdmin = role == 'admin' || role == 'dispatcher';
return ResponsiveBody(
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 980;
final schedulePanel = _SchedulePanel(isAdmin: isAdmin);
final swapsPanel = _SwapRequestsPanel(isAdmin: isAdmin);
final generatorPanel = _ScheduleGeneratorPanel(enabled: isAdmin);
if (isWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: schedulePanel),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: Column(
children: [
if (isAdmin) generatorPanel,
if (isAdmin) const SizedBox(height: 16),
Expanded(child: swapsPanel),
],
),
),
],
);
}
return DefaultTabController(
length: isAdmin ? 3 : 2,
child: Column(
children: [
const SizedBox(height: 8),
TabBar(
tabs: [
const Tab(text: 'Schedule'),
const Tab(text: 'Swaps'),
if (isAdmin) const Tab(text: 'Generator'),
],
),
const SizedBox(height: 8),
Expanded(
child: TabBarView(
children: [
schedulePanel,
swapsPanel,
if (isAdmin) generatorPanel,
],
),
),
],
),
);
},
),
);
}
}
class _SchedulePanel extends ConsumerWidget {
const _SchedulePanel({required this.isAdmin});
final bool isAdmin;
@override
Widget build(BuildContext context, WidgetRef ref) {
final schedulesAsync = ref.watch(dutySchedulesProvider);
final profilesAsync = ref.watch(profilesProvider);
final currentUserId = ref.watch(currentUserIdProvider);
final showPast = ref.watch(showPastSchedulesProvider);
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Text(
'Duty Schedules',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const Spacer(),
FilterChip(
label: const Text('Show past'),
selected: showPast,
onSelected: (v) =>
ref.read(showPastSchedulesProvider.notifier).state = v,
),
],
),
),
const SizedBox(height: 4),
Expanded(
child: schedulesAsync.when(
data: (allSchedules) {
final now = AppTime.now();
final today = DateTime(now.year, now.month, now.day);
// Exclude overtime schedules they only belong in the Logbook.
final nonOvertime = allSchedules
.where((s) => s.shiftType != 'overtime')
.toList();
final schedules = showPast
? nonOvertime
: nonOvertime
.where((s) => !s.endTime.isBefore(today))
.toList();
if (schedules.isEmpty) {
return const Center(child: Text('No schedules yet.'));
}
final Map<String, Profile> profileById = {
for (final profile in profilesAsync.valueOrNull ?? [])
profile.id: profile,
};
final grouped = <DateTime, List<DutySchedule>>{};
for (final schedule in schedules) {
final day = DateTime(
schedule.startTime.year,
schedule.startTime.month,
schedule.startTime.day,
);
grouped.putIfAbsent(day, () => []).add(schedule);
}
final days = grouped.keys.toList()..sort();
return ListView.builder(
padding: const EdgeInsets.only(bottom: 24),
itemCount: days.length,
itemBuilder: (context, index) {
final day = days[index];
final items = grouped[day]!
..sort((a, b) => a.startTime.compareTo(b.startTime));
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_dayOfWeek(day)}, ${AppTime.formatDate(day)}',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
...items.map(
(schedule) => _ScheduleTile(
schedule: schedule,
displayName: _scheduleName(
profileById,
schedule,
isAdmin,
),
relieverLabels: _relieverLabelsFromIds(
schedule.relieverIds,
profileById,
),
isMine: schedule.userId == currentUserId,
isAdmin: isAdmin,
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) =>
Center(child: Text('Failed to load schedules: $error')),
),
),
],
);
}
String _scheduleName(
Map<String, Profile> profileById,
DutySchedule schedule,
bool isAdmin,
) {
final profile = profileById[schedule.userId];
final name = profile?.fullName.isNotEmpty == true
? profile!.fullName
: schedule.userId;
return '${_shiftLabel(schedule.shiftType)} · $name';
}
static String _dayOfWeek(DateTime day) {
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
return days[day.weekday - 1];
}
String _shiftLabel(String value) {
switch (value) {
case 'am':
return 'AM Duty';
case 'pm':
return 'PM Duty';
case 'on_call':
return 'On Call';
case 'normal':
return 'Normal';
case 'weekend':
return 'Weekend';
default:
return value;
}
}
List<String> _relieverLabelsFromIds(
List<String> relieverIds,
Map<String, Profile> profileById,
) {
if (relieverIds.isEmpty) return const [];
return relieverIds
.map(
(id) => profileById[id]?.fullName.isNotEmpty == true
? profileById[id]!.fullName
: id,
)
.toList();
}
}
class _ScheduleTile extends ConsumerWidget {
const _ScheduleTile({
required this.schedule,
required this.displayName,
required this.relieverLabels,
required this.isMine,
required this.isAdmin,
});
final DutySchedule schedule;
final String displayName;
final List<String> relieverLabels;
final bool isMine;
final bool isAdmin;
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentUserId = ref.watch(currentUserIdProvider);
final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? [];
final now = AppTime.now();
final isPast = schedule.startTime.isBefore(now);
final hasRequestedSwap = swaps.any(
(swap) =>
swap.requesterScheduleId == schedule.id &&
swap.requesterId == currentUserId &&
swap.status == 'pending',
);
final canRequestSwap = isMine && schedule.status != 'absent' && !isPast;
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'${AppTime.formatTime(schedule.startTime)} - ${AppTime.formatTime(schedule.endTime)}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),
Text(
_statusLabel(schedule.status),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: _statusColor(context, schedule.status),
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (isAdmin)
IconButton(
tooltip: 'Edit schedule',
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',
),
),
],
),
],
),
if (relieverLabels.isNotEmpty)
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: const Text('Relievers'),
children: [
for (final label in relieverLabels)
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
left: 8,
right: 8,
bottom: 6,
),
child: Text(label),
),
),
],
),
],
),
),
);
}
Future<void> _editSchedule(BuildContext context, WidgetRef ref) async {
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
final staff =
profiles
.where(
(p) =>
p.role == 'it_staff' ||
p.role == 'admin' ||
p.role == 'dispatcher',
)
.toList()
..sort((a, b) => a.fullName.compareTo(b.fullName));
if (staff.isEmpty) return;
var selectedUserId = schedule.userId;
var selectedShift = schedule.shiftType;
var selectedDate = DateTime(
schedule.startTime.year,
schedule.startTime.month,
schedule.startTime.day,
);
var startTime = TimeOfDay.fromDateTime(schedule.startTime);
var endTime = TimeOfDay.fromDateTime(schedule.endTime);
final confirmed = await m3ShowDialog<bool>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Edit Schedule'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
initialValue: selectedUserId,
items: [
for (final p in staff)
DropdownMenuItem(
value: p.id,
child: Text(
p.fullName.isNotEmpty ? p.fullName : p.id,
),
),
],
onChanged: (v) {
if (v != null) setState(() => selectedUserId = v);
},
decoration: const InputDecoration(labelText: 'Assignee'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedShift,
items: const [
DropdownMenuItem(value: 'am', child: Text('AM Duty')),
DropdownMenuItem(value: 'pm', child: Text('PM Duty')),
DropdownMenuItem(
value: 'on_call',
child: Text('On Call'),
),
DropdownMenuItem(
value: 'normal',
child: Text('Normal'),
),
],
onChanged: (v) {
if (v != null) setState(() => selectedShift = v);
},
decoration: const InputDecoration(
labelText: 'Shift type',
),
),
const SizedBox(height: 12),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() => selectedDate = picked);
}
},
child: InputDecorator(
decoration: const InputDecoration(labelText: 'Date'),
child: Text(AppTime.formatDate(selectedDate)),
),
),
const SizedBox(height: 12),
InkWell(
onTap: () async {
final picked = await showTimePicker(
context: context,
initialTime: startTime,
);
if (picked != null) {
setState(() => startTime = picked);
}
},
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Start time',
),
child: Text(startTime.format(context)),
),
),
const SizedBox(height: 12),
InkWell(
onTap: () async {
final picked = await showTimePicker(
context: context,
initialTime: endTime,
);
if (picked != null) {
setState(() => endTime = picked);
}
},
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'End time',
),
child: Text(endTime.format(context)),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Save'),
),
],
);
},
);
},
);
if (confirmed != true || !context.mounted) return;
final startDateTime = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
startTime.hour,
startTime.minute,
);
var endDateTime = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
endTime.hour,
endTime.minute,
);
if (!endDateTime.isAfter(startDateTime)) {
endDateTime = endDateTime.add(const Duration(days: 1));
}
try {
await ref
.read(workforceControllerProvider)
.updateSchedule(
scheduleId: schedule.id,
userId: selectedUserId,
shiftType: selectedShift,
startTime: startDateTime,
endTime: endDateTime,
);
ref.invalidate(dutySchedulesProvider);
} catch (e) {
if (!context.mounted) return;
showErrorSnackBar(context, 'Update failed: $e');
}
}
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) {
case 'arrival':
return 'Arrival';
case 'late':
return 'Late';
case 'absent':
return 'Absent';
default:
return 'Scheduled';
}
}
Color _statusColor(BuildContext context, String status) {
switch (status) {
case 'arrival':
return Colors.green;
case 'late':
return Colors.orange;
case 'absent':
return Colors.red;
default:
return Theme.of(context).colorScheme.onSurfaceVariant;
}
}
}
class _DraftSchedule {
_DraftSchedule({
required this.localId,
required this.userId,
required this.shiftType,
required this.startTime,
required this.endTime,
List<String>? relieverIds,
}) : relieverIds = relieverIds ?? <String>[];
final int localId;
String userId;
String shiftType;
DateTime startTime;
DateTime endTime;
List<String> relieverIds;
}
class _RotationEntry {
_RotationEntry({
required this.userId,
required this.shiftType,
required this.startTime,
});
final String userId;
final String shiftType;
final DateTime startTime;
}
class _ShiftTemplate {
_ShiftTemplate({
required this.startHour,
required this.startMinute,
required this.duration,
});
final int startHour;
final int startMinute;
final Duration duration;
DateTime buildStart(DateTime day) {
return tz.TZDateTime(
tz.local,
day.year,
day.month,
day.day,
startHour,
startMinute,
);
}
DateTime buildEnd(DateTime start) {
return start.add(duration);
}
}
class _ScheduleGeneratorPanel extends ConsumerStatefulWidget {
const _ScheduleGeneratorPanel({required this.enabled});
final bool enabled;
@override
ConsumerState<_ScheduleGeneratorPanel> createState() =>
_ScheduleGeneratorPanelState();
}
class _ScheduleGeneratorPanelState
extends ConsumerState<_ScheduleGeneratorPanel> {
DateTime? _startDate;
DateTime? _endDate;
bool _isGenerating = false;
bool _isSaving = false;
int _draftCounter = 0;
List<_DraftSchedule> _draftSchedules = [];
List<String> _warnings = [];
@override
Widget build(BuildContext context) {
if (!widget.enabled) {
return const SizedBox.shrink();
}
final isRamadan = ref.watch(isRamadanActiveProvider);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Schedule Generator',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (isRamadan) ...[
const SizedBox(width: 8),
Chip(
label: const Text('Ramadan'),
avatar: const Icon(Icons.nights_stay, size: 16),
backgroundColor: Theme.of(
context,
).colorScheme.tertiaryContainer,
labelStyle: TextStyle(
color: Theme.of(context).colorScheme.onTertiaryContainer,
),
),
],
const Spacer(),
IconButton(
tooltip: 'Rotation settings',
icon: const Icon(Icons.settings_outlined),
onPressed: () => showRotationSettings(context, ref),
),
],
),
const SizedBox(height: 12),
_dateField(
context,
label: 'Start date',
value: _startDate,
onTap: () => _pickDate(isStart: true),
),
const SizedBox(height: 8),
_dateField(
context,
label: 'End date',
value: _endDate,
onTap: () => _pickDate(isStart: false),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton(
onPressed: _isGenerating ? null : _handleGenerate,
child: _isGenerating
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Generate preview'),
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: _draftSchedules.isEmpty ? null : _clearDraft,
child: const Text('Clear'),
),
],
),
if (_warnings.isNotEmpty) ...[
const SizedBox(height: 12),
_buildWarningPanel(context),
],
if (_draftSchedules.isNotEmpty) ...[
const SizedBox(height: 12),
_buildDraftHeader(context),
const SizedBox(height: 8),
Flexible(fit: FlexFit.loose, child: _buildDraftList(context)),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
onPressed: _isSaving ? null : _addDraft,
icon: const Icon(Icons.add),
label: const Text('Add shift'),
),
const Spacer(),
FilledButton.icon(
onPressed: _isSaving ? null : _commitDraft,
icon: const Icon(Icons.check_circle),
label: _isSaving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Commit schedule'),
),
],
),
],
],
),
),
);
}
Widget _dateField(
BuildContext context, {
required String label,
required DateTime? value,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(labelText: label),
child: Text(value == null ? 'Select date' : AppTime.formatDate(value)),
),
);
}
Future<void> _pickDate({required bool isStart}) async {
final now = AppTime.now();
final initial = isStart ? _startDate ?? now : _endDate ?? _startDate ?? now;
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(now.year - 1),
lastDate: DateTime(now.year + 2),
);
if (picked == null) return;
setState(() {
if (isStart) {
_startDate = picked;
} else {
_endDate = picked;
}
});
}
Future<void> _handleGenerate() async {
final start = _startDate;
final end = _endDate;
if (start == null || end == null) {
showWarningSnackBar(context, 'Select a date range.');
return;
}
if (end.isBefore(start)) {
showWarningSnackBar(context, 'End date must be after start date.');
return;
}
setState(() => _isGenerating = true);
try {
final staff = _sortedStaff();
if (staff.isEmpty) {
if (!mounted) return;
showWarningSnackBar(context, 'No staff available for scheduling.');
return;
}
final rotationConfig = ref.read(rotationConfigProvider).valueOrNull;
final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? [];
final templates = _buildTemplates(schedules);
final generated = _generateDrafts(
start,
end,
staff,
schedules,
templates,
rotationConfig: rotationConfig,
);
generated.sort((a, b) => a.startTime.compareTo(b.startTime));
final warnings = _buildWarnings(start, end, generated);
if (!mounted) return;
setState(() {
_draftSchedules = generated;
_warnings = warnings;
});
if (generated.isEmpty) {
showInfoSnackBar(context, 'No shifts could be generated.');
}
} finally {
if (mounted) {
setState(() => _isGenerating = false);
}
}
}
void _clearDraft() {
setState(() {
_draftSchedules = [];
_warnings = [];
});
}
Widget _buildWarningPanel(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).compactCardRadius,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Uncovered shifts',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onTertiaryContainer,
),
),
const SizedBox(height: 6),
for (final warning in _warnings)
Text(
warning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer,
),
),
],
),
),
],
),
);
}
Widget _buildDraftHeader(BuildContext context) {
final start = _startDate == null ? '' : AppTime.formatDate(_startDate!);
final end = _endDate == null ? '' : AppTime.formatDate(_endDate!);
return Row(
children: [
Text(
'Preview (${_draftSchedules.length})',
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
const Spacer(),
if (start.isNotEmpty && end.isNotEmpty)
Text('$start$end', style: Theme.of(context).textTheme.bodySmall),
],
);
}
Widget _buildDraftList(BuildContext context) {
return ListView.separated(
primary: false,
itemCount: _draftSchedules.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final draft = _draftSchedules[index];
final profileById = _profileById();
final profile = profileById[draft.userId];
final userLabel = profile?.fullName.isNotEmpty == true
? profile!.fullName
: draft.userId;
final relieverLabels = draft.relieverIds
.map(
(id) => profileById[id]?.fullName.isNotEmpty == true
? profileById[id]!.fullName
: id,
)
.toList();
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_shiftLabel(draft.shiftType)} · $userLabel',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
'${AppTime.formatDate(draft.startTime)} · ${AppTime.formatTime(draft.startTime)} - ${AppTime.formatTime(draft.endTime)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
IconButton(
tooltip: 'Edit',
onPressed: () => _editDraft(draft),
icon: const Icon(Icons.edit),
),
IconButton(
tooltip: 'Delete',
onPressed: () => _deleteDraft(draft),
icon: const Icon(Icons.delete_outline),
),
],
),
if (relieverLabels.isNotEmpty)
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: const Text('Relievers'),
children: [
for (final label in relieverLabels)
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
left: 8,
right: 8,
bottom: 6,
),
child: Text(label),
),
),
],
),
],
),
),
);
},
);
}
void _addDraft() {
_openDraftEditor();
}
void _editDraft(_DraftSchedule draft) {
_openDraftEditor(existing: draft);
}
void _deleteDraft(_DraftSchedule draft) {
setState(() {
_draftSchedules.removeWhere((item) => item.localId == draft.localId);
_warnings = _buildWarnings(
_startDate ?? AppTime.now(),
_endDate ?? AppTime.now(),
_draftSchedules,
);
});
}
Future<void> _openDraftEditor({_DraftSchedule? existing}) async {
final staff = _sortedStaff();
if (staff.isEmpty) {
showWarningSnackBar(context, 'No IT staff available.');
return;
}
final start = existing?.startTime ?? _startDate ?? AppTime.now();
var selectedDate = DateTime(start.year, start.month, start.day);
var selectedUserId = existing?.userId ?? staff.first.id;
var selectedShift = existing?.shiftType ?? 'am';
var startTime = TimeOfDay.fromDateTime(existing?.startTime ?? start);
var endTime = TimeOfDay.fromDateTime(
existing?.endTime ?? start.add(const Duration(hours: 8)),
);
final result = await m3ShowDialog<_DraftSchedule>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: Text(existing == null ? 'Add shift' : 'Edit shift'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
initialValue: selectedUserId,
items: [
for (final profile in staff)
DropdownMenuItem(
value: profile.id,
child: Text(
profile.fullName.isNotEmpty
? profile.fullName
: profile.id,
),
),
],
onChanged: (value) {
if (value == null) return;
setDialogState(() => selectedUserId = value);
},
decoration: const InputDecoration(labelText: 'Assignee'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedShift,
items: const [
DropdownMenuItem(value: 'am', child: Text('AM Duty')),
DropdownMenuItem(
value: 'normal',
child: Text('Normal Duty'),
),
DropdownMenuItem(
value: 'on_call',
child: Text('On Call'),
),
DropdownMenuItem(value: 'pm', child: Text('PM Duty')),
],
onChanged: (value) {
if (value == null) return;
setDialogState(() => selectedShift = value);
},
decoration: const InputDecoration(
labelText: 'Shift type',
),
),
const SizedBox(height: 12),
_dialogDateField(
label: 'Date',
value: AppTime.formatDate(selectedDate),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
);
if (picked == null) return;
setDialogState(() {
selectedDate = picked;
});
},
),
const SizedBox(height: 12),
_dialogTimeField(
label: 'Start time',
value: startTime,
onTap: () async {
final picked = await showTimePicker(
context: context,
initialTime: startTime,
);
if (picked == null) return;
setDialogState(() => startTime = picked);
},
),
const SizedBox(height: 12),
_dialogTimeField(
label: 'End time',
value: endTime,
onTap: () async {
final picked = await showTimePicker(
context: context,
initialTime: endTime,
);
if (picked == null) return;
setDialogState(() => endTime = picked);
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final startDateTime = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
startTime.hour,
startTime.minute,
);
var endDateTime = DateTime(
selectedDate.year,
selectedDate.month,
selectedDate.day,
endTime.hour,
endTime.minute,
);
if (!endDateTime.isAfter(startDateTime)) {
endDateTime = endDateTime.add(const Duration(days: 1));
}
final draft =
existing ??
_DraftSchedule(
localId: _draftCounter++,
userId: selectedUserId,
shiftType: selectedShift,
startTime: startDateTime,
endTime: endDateTime,
);
draft
..userId = selectedUserId
..shiftType = selectedShift
..startTime = startDateTime
..endTime = endDateTime;
Navigator.of(dialogContext).pop(draft);
},
child: const Text('Save'),
),
],
);
},
);
},
);
if (!mounted || result == null) return;
setState(() {
if (existing == null) {
_draftSchedules.add(result);
}
_draftSchedules.sort((a, b) => a.startTime.compareTo(b.startTime));
_warnings = _buildWarnings(
_startDate ?? result.startTime,
_endDate ?? result.endTime,
_draftSchedules,
);
});
}
Widget _dialogDateField({
required String label,
required String value,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(labelText: label),
child: Text(value),
),
);
}
Widget _dialogTimeField({
required String label,
required TimeOfDay value,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(labelText: label),
child: Text(value.format(context)),
),
);
}
Future<void> _commitDraft() async {
final start = _startDate;
final end = _endDate;
if (start == null || end == null) {
showWarningSnackBar(context, 'Select a date range.');
return;
}
final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? [];
final conflict = _findConflict(_draftSchedules, schedules);
if (conflict != null) {
showInfoSnackBar(context, conflict);
return;
}
final payload = _draftSchedules
.map(
(draft) => {
'user_id': draft.userId,
'shift_type': draft.shiftType,
'start_time': draft.startTime.toIso8601String(),
'end_time': draft.endTime.toIso8601String(),
'status': 'scheduled',
'reliever_ids': draft.relieverIds,
},
)
.toList();
setState(() => _isSaving = true);
try {
await ref.read(workforceControllerProvider).insertSchedules(payload);
ref.invalidate(dutySchedulesProvider);
if (!mounted) return;
showSuccessSnackBar(context, 'Schedule committed.');
setState(() {
_draftSchedules = [];
_warnings = [];
});
} catch (error) {
if (!mounted) return;
showErrorSnackBar(context, 'Commit failed: $error');
} finally {
if (mounted) {
setState(() => _isSaving = false);
}
}
}
List<Profile> _sortedStaff() {
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
final staff = profiles
.where(
(profile) =>
profile.role == 'it_staff' ||
profile.role == 'admin' ||
profile.role == 'dispatcher',
)
.toList();
staff.sort((a, b) {
final nameA = a.fullName.isNotEmpty ? a.fullName : a.id;
final nameB = b.fullName.isNotEmpty ? b.fullName : b.id;
final result = nameA.compareTo(nameB);
if (result != 0) return result;
return a.id.compareTo(b.id);
});
return staff;
}
Map<String, Profile> _profileById() {
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
return {for (final profile in profiles) profile.id: profile};
}
Map<String, _ShiftTemplate> _buildTemplates(List<DutySchedule> schedules) {
final templates = <String, _ShiftTemplate>{};
for (final schedule in schedules) {
final key = _normalizeShiftType(schedule.shiftType);
if (templates.containsKey(key)) continue;
final start = schedule.startTime;
var end = schedule.endTime;
if (!end.isAfter(start)) {
end = end.add(const Duration(days: 1));
}
templates[key] = _ShiftTemplate(
startHour: start.hour,
startMinute: start.minute,
duration: end.difference(start),
);
}
templates['am'] = _ShiftTemplate(
startHour: 7,
startMinute: 0,
duration: const Duration(hours: 8),
);
templates['pm'] = _ShiftTemplate(
startHour: 15,
startMinute: 0,
duration: const Duration(hours: 8),
);
templates['on_call'] = _ShiftTemplate(
startHour: 23,
startMinute: 0,
duration: const Duration(hours: 8),
);
// Weekend Saturday on_call: 5PM (17:00) to 8AM (08:00) next day = 15 hours
templates['on_call_saturday'] = _ShiftTemplate(
startHour: 17,
startMinute: 0,
duration: const Duration(hours: 15),
);
// Weekend Sunday on_call: 5PM (17:00) to 7AM (07:00) next day = 14 hours
templates['on_call_sunday'] = _ShiftTemplate(
startHour: 17,
startMinute: 0,
duration: const Duration(hours: 14),
);
// Default normal shift (8am-5pm = 9 hours)
templates['normal'] = _ShiftTemplate(
startHour: 8,
startMinute: 0,
duration: const Duration(hours: 9),
);
// Islam Ramadan normal shift (8am-4pm = 8 hours)
templates['normal_ramadan_islam'] = _ShiftTemplate(
startHour: 8,
startMinute: 0,
duration: const Duration(hours: 8),
);
// Non-Islam Ramadan normal shift (8am-5pm = 9 hours, same as default)
templates['normal_ramadan_other'] = _ShiftTemplate(
startHour: 8,
startMinute: 0,
duration: const Duration(hours: 9),
);
return templates;
}
List<_DraftSchedule> _generateDrafts(
DateTime start,
DateTime end,
List<Profile> staff,
List<DutySchedule> schedules,
Map<String, _ShiftTemplate> templates, {
RotationConfig? rotationConfig,
}) {
final draft = <_DraftSchedule>[];
final normalizedStart = DateTime(start.year, start.month, start.day);
final normalizedEnd = DateTime(end.year, end.month, end.day);
final existing = schedules;
final excludedIds = rotationConfig?.excludedStaffIds ?? [];
// Only IT Staff rotate through AM/PM/on_call shifts (minus excluded)
final allItStaff = staff.where((p) => p.role == 'it_staff').toList();
final itStaff = allItStaff
.where((p) => !excludedIds.contains(p.id))
.toList();
// Sort IT staff by configured rotation order if available
if (rotationConfig != null && rotationConfig.rotationOrder.isNotEmpty) {
final order = rotationConfig.rotationOrder;
itStaff.sort((a, b) {
final ai = order.indexOf(a.id);
final bi = order.indexOf(b.id);
final aIdx = ai == -1 ? order.length : ai;
final bIdx = bi == -1 ? order.length : bi;
return aIdx.compareTo(bIdx);
});
}
// Non-Islam IT staff for Friday AM rotation
final fridayAmStaff = itStaff.where((p) => p.religion != 'islam').toList();
if (rotationConfig != null && rotationConfig.fridayAmOrder.isNotEmpty) {
final order = rotationConfig.fridayAmOrder;
fridayAmStaff.sort((a, b) {
final ai = order.indexOf(a.id);
final bi = order.indexOf(b.id);
final aIdx = ai == -1 ? order.length : ai;
final bIdx = bi == -1 ? order.length : bi;
return aIdx.compareTo(bIdx);
});
}
// Admin/Dispatcher always get normal shift (no rotation)
final nonRotating = staff.where((p) => p.role != 'it_staff').toList();
// Track Friday AM rotation index separately
var fridayAmRotationIdx = 0;
var isFirstWeek = true;
var weekStart = _startOfWeek(normalizedStart);
while (!weekStart.isAfter(normalizedEnd)) {
final weekEnd = weekStart.add(const Duration(days: 6));
final prevWeekStart = weekStart.subtract(const Duration(days: 7));
final prevWeekEnd = weekStart.subtract(const Duration(days: 1));
final lastWeek = <_RotationEntry>[
...existing
.where(
(schedule) =>
!schedule.startTime.isBefore(prevWeekStart) &&
!schedule.startTime.isAfter(prevWeekEnd),
)
.map(
(schedule) => _RotationEntry(
userId: schedule.userId,
shiftType: schedule.shiftType,
startTime: schedule.startTime,
),
),
...draft
.where(
(item) =>
!item.startTime.isBefore(prevWeekStart) &&
!item.startTime.isAfter(prevWeekEnd),
)
.map(
(item) => _RotationEntry(
userId: item.userId,
shiftType: item.shiftType,
startTime: item.startTime,
),
),
];
// Rotation indices only for IT Staff
int amBaseIndex;
int pmBaseIndex;
if (isFirstWeek && rotationConfig?.initialAmStaffId != null) {
// Use configured initial AM
final idx = itStaff.indexWhere(
(p) => p.id == rotationConfig!.initialAmStaffId,
);
amBaseIndex = idx != -1 ? idx : 0;
} else {
amBaseIndex = _nextIndexFromLastWeek(
shiftType: 'am',
staff: itStaff,
lastWeek: lastWeek,
defaultIndex: 0,
);
}
if (isFirstWeek && rotationConfig?.initialPmStaffId != null) {
// Use configured initial PM
final idx = itStaff.indexWhere(
(p) => p.id == rotationConfig!.initialPmStaffId,
);
pmBaseIndex = idx != -1 ? idx : (itStaff.length > 1 ? 2 : 0);
} else {
// PM duty is 2 positions after AM in the rotation to avoid conflicts
final defaultPmIndex = itStaff.length > 2
? (amBaseIndex + 2) % itStaff.length
: (itStaff.length > 1 ? (amBaseIndex + 1) % itStaff.length : 0);
pmBaseIndex = _nextIndexFromLastWeek(
shiftType: 'pm',
staff: itStaff,
lastWeek: lastWeek,
defaultIndex: defaultPmIndex,
);
}
isFirstWeek = false;
final amUserId = itStaff.isEmpty
? null
: itStaff[amBaseIndex % itStaff.length].id;
final pmUserId = itStaff.isEmpty
? null
: itStaff[pmBaseIndex % itStaff.length].id;
final pmRelievers = _buildRelievers(pmBaseIndex, itStaff);
for (
var day = weekStart;
!day.isAfter(weekEnd);
day = day.add(const Duration(days: 1))
) {
if (day.isBefore(normalizedStart) || day.isAfter(normalizedEnd)) {
continue;
}
final isWeekend =
day.weekday == DateTime.saturday || day.weekday == DateTime.sunday;
final dayIsRamadan = isApproximateRamadan(day);
if (isWeekend) {
final isSaturday = day.weekday == DateTime.saturday;
if (isSaturday) {
// Saturday: AM person gets normal shift, PM person gets weekend on_call
if (amUserId != null) {
_tryAddDraft(
draft,
existing,
templates,
'normal',
amUserId,
day,
const [],
);
}
if (pmUserId != null) {
_tryAddDraft(
draft,
existing,
templates,
'on_call_saturday',
pmUserId,
day,
pmRelievers,
);
}
} else {
// Sunday: PM person gets both normal and weekend on_call
if (pmUserId != null) {
_tryAddDraft(
draft,
existing,
templates,
'normal',
pmUserId,
day,
const [],
);
_tryAddDraft(
draft,
existing,
templates,
'on_call_sunday',
pmUserId,
day,
pmRelievers,
);
}
}
} else {
// Weekday: IT Staff rotate AM/PM/on_call
final isFriday = day.weekday == DateTime.friday;
final profileMap = _profileById();
final amProfile = amUserId != null ? profileMap[amUserId] : null;
// Friday AM: use separate non-Islam rotation
String? effectiveAmUserId = amUserId;
if (isFriday && (dayIsRamadan || true)) {
// On Fridays, only non-Muslim IT Staff can take AM
if (amProfile?.religion == 'islam') {
// Use Friday AM rotation list
if (fridayAmStaff.isNotEmpty) {
effectiveAmUserId =
fridayAmStaff[fridayAmRotationIdx % fridayAmStaff.length]
.id;
fridayAmRotationIdx++;
} else {
effectiveAmUserId = null; // No eligible staff
}
}
}
if (effectiveAmUserId != null) {
_tryAddDraft(
draft,
existing,
templates,
'am',
effectiveAmUserId,
day,
const [],
);
}
if (pmUserId != null) {
_tryAddDraft(
draft,
existing,
templates,
'pm',
pmUserId,
day,
pmRelievers,
);
_tryAddDraft(
draft,
existing,
templates,
'on_call',
pmUserId,
day,
pmRelievers,
);
}
// Remaining IT Staff (including excluded) get normal shift
final assignedToday = <String?>[
effectiveAmUserId,
pmUserId,
].whereType<String>().toSet();
for (final profile in allItStaff) {
if (assignedToday.contains(profile.id)) continue;
final normalKey = dayIsRamadan && profile.religion == 'islam'
? 'normal_ramadan_islam'
: dayIsRamadan
? 'normal_ramadan_other'
: 'normal';
_tryAddDraft(
draft,
existing,
templates,
normalKey,
profile.id,
day,
const [],
displayShiftType: 'normal',
);
}
// Admin/Dispatcher always get normal shift (no rotation)
for (final profile in nonRotating) {
final normalKey = dayIsRamadan && profile.religion == 'islam'
? 'normal_ramadan_islam'
: dayIsRamadan
? 'normal_ramadan_other'
: 'normal';
_tryAddDraft(
draft,
existing,
templates,
normalKey,
profile.id,
day,
const [],
displayShiftType: 'normal',
);
}
}
}
weekStart = weekStart.add(const Duration(days: 7));
}
return draft;
}
void _tryAddDraft(
List<_DraftSchedule> draft,
List<DutySchedule> existing,
Map<String, _ShiftTemplate> templates,
String shiftType,
String userId,
DateTime day,
List<String> relieverIds, {
String? displayShiftType,
}) {
final template = templates[_normalizeShiftType(shiftType)]!;
final start = template.buildStart(day);
final end = template.buildEnd(start);
final candidate = _DraftSchedule(
localId: _draftCounter++,
userId: userId,
shiftType: displayShiftType ?? shiftType,
startTime: start,
endTime: end,
relieverIds: relieverIds,
);
if (_hasConflict(candidate, draft, existing)) {
return;
}
draft.add(candidate);
}
int _nextIndexFromLastWeek({
required String shiftType,
required List<Profile> staff,
required Iterable<_RotationEntry> lastWeek,
required int defaultIndex,
}) {
if (staff.isEmpty) return 0;
final normalized = _normalizeShiftType(shiftType);
final lastAssignment =
lastWeek
.where(
(entry) => _normalizeShiftType(entry.shiftType) == normalized,
)
.toList()
..sort((a, b) => a.startTime.compareTo(b.startTime));
if (lastAssignment.isNotEmpty) {
final lastUserId = lastAssignment.last.userId;
final index = staff.indexWhere((profile) => profile.id == lastUserId);
if (index != -1) {
return (index + 1) % staff.length;
}
}
return defaultIndex % staff.length;
}
List<String> _buildRelievers(int primaryIndex, List<Profile> staff) {
if (staff.length <= 1) return const [];
final relievers = <String>[];
for (var offset = 1; offset < staff.length; offset += 1) {
relievers.add(staff[(primaryIndex + offset) % staff.length].id);
if (relievers.length == 3) break;
}
return relievers;
}
bool _hasConflict(
_DraftSchedule candidate,
List<_DraftSchedule> drafts,
List<DutySchedule> existing,
) {
for (final draft in drafts) {
if (draft.userId != candidate.userId) continue;
if (_overlaps(
candidate.startTime,
candidate.endTime,
draft.startTime,
draft.endTime,
)) {
return true;
}
}
for (final schedule in existing) {
if (schedule.userId != candidate.userId) continue;
if (_overlaps(
candidate.startTime,
candidate.endTime,
schedule.startTime,
schedule.endTime,
)) {
return true;
}
}
return false;
}
String? _findConflict(
List<_DraftSchedule> drafts,
List<DutySchedule> existing,
) {
for (final draft in drafts) {
if (_hasConflict(
draft,
drafts.where((d) => d.localId != draft.localId).toList(),
existing,
)) {
return 'Conflict found for ${AppTime.formatDate(draft.startTime)}.';
}
}
return null;
}
bool _overlaps(
DateTime startA,
DateTime endA,
DateTime startB,
DateTime endB,
) {
return startA.isBefore(endB) && endA.isAfter(startB);
}
List<String> _buildWarnings(
DateTime start,
DateTime end,
List<_DraftSchedule> drafts,
) {
final warnings = <String>[];
var day = DateTime(start.year, start.month, start.day);
final lastDay = DateTime(end.year, end.month, end.day);
while (!day.isAfter(lastDay)) {
final isWeekend =
day.weekday == DateTime.saturday || day.weekday == DateTime.sunday;
final required = <String>{'on_call'};
if (isWeekend) {
required.add('normal');
} else {
required.addAll({'am', 'pm'});
}
final available = drafts
.where(
(draft) =>
draft.startTime.year == day.year &&
draft.startTime.month == day.month &&
draft.startTime.day == day.day,
)
.map((draft) => _normalizeShiftType(draft.shiftType))
.toSet();
for (final shift in required) {
if (!available.contains(shift)) {
warnings.add(
'${AppTime.formatDate(day)} missing ${_shiftLabel(shift)}',
);
}
}
day = day.add(const Duration(days: 1));
}
return warnings;
}
DateTime _startOfWeek(DateTime day) {
return day.subtract(Duration(days: day.weekday - 1));
}
String _normalizeShiftType(String value) {
return value;
}
String _shiftLabel(String value) {
switch (value) {
case 'am':
return 'AM Duty';
case 'pm':
return 'PM Duty';
case 'on_call':
return 'On Call';
case 'normal':
return 'Normal';
case 'weekend':
return 'Weekend';
default:
return value;
}
}
}
class _SwapRequestsPanel extends ConsumerWidget {
const _SwapRequestsPanel({required this.isAdmin});
final bool isAdmin;
@override
Widget build(BuildContext context, WidgetRef ref) {
final swapsAsync = ref.watch(swapRequestsProvider);
final schedulesAsync = ref.watch(dutySchedulesProvider);
final profilesAsync = ref.watch(profilesProvider);
final currentUserId = ref.watch(currentUserIdProvider);
final Map<String, DutySchedule> scheduleById = {
for (final schedule in schedulesAsync.valueOrNull ?? [])
schedule.id: schedule,
};
final Map<String, Profile> profileById = {
for (final profile in profilesAsync.valueOrNull ?? [])
profile.id: profile,
};
return swapsAsync.when(
data: (items) {
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 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
? requesterProfile!.fullName
: item.requesterId;
final recipient = recipientProfile?.fullName.isNotEmpty == true
? recipientProfile!.fullName
: item.recipientId;
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 =
(isPending && (isAdmin || item.recipientId == currentUserId)) ||
(isAdmin && item.status == 'admin_review');
final canEscalate = item.requesterId == currentUserId && isPending;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$requester$recipient',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(subtitle),
const SizedBox(height: 6),
Text('Status: ${item.status}'),
const SizedBox(height: 12),
if (relieverLabels.isNotEmpty)
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: const Text('Relievers'),
children: [
for (final label in relieverLabels)
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
left: 8,
right: 8,
bottom: 6,
),
child: Text(label),
),
),
],
),
Row(
children: [
if (canRespond) ...[
OutlinedButton(
onPressed: () => _respond(ref, item, 'accepted'),
child: const Text('Accept'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => _respond(ref, item, 'rejected'),
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(
onPressed: () =>
_respond(ref, item, 'admin_review'),
child: const Text('Escalate'),
),
],
],
),
if (item.chatThreadId != null)
_SwapChatSection(
threadId: item.chatThreadId!,
currentUserId: currentUserId ?? '',
profileById: profileById,
),
],
),
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Failed to load swaps: $error')),
);
}
Future<void> _respond(
WidgetRef ref,
SwapRequest request,
String action,
) async {
await ref
.read(workforceControllerProvider)
.respondSwap(swapId: request.id, action: action);
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 m3ShowDialog<Profile?>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Change recipient'),
content: StatefulBuilder(
builder: (context, setState) => DropdownButtonFormField<Profile>(
initialValue: 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':
return 'AM Duty';
case 'pm':
return 'PM Duty';
case 'on_call':
return 'On Call';
case 'normal':
return 'Normal';
case 'weekend':
return 'Weekend';
default:
return value;
}
}
List<String> _relieverLabelsFromIds(
List<String> relieverIds,
Map<String, Profile> profileById,
) {
if (relieverIds.isEmpty) return const [];
return relieverIds
.map(
(id) => profileById[id]?.fullName.isNotEmpty == true
? profileById[id]!.fullName
: id,
)
.toList();
}
}
/// Expandable chat section within a swap request card.
class _SwapChatSection extends ConsumerStatefulWidget {
const _SwapChatSection({
required this.threadId,
required this.currentUserId,
required this.profileById,
});
final String threadId;
final String currentUserId;
final Map<String, Profile> profileById;
@override
ConsumerState<_SwapChatSection> createState() => _SwapChatSectionState();
}
class _SwapChatSectionState extends ConsumerState<_SwapChatSection> {
final _msgController = TextEditingController();
bool _expanded = false;
@override
void dispose() {
_msgController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
const Icon(Icons.chat_bubble_outline, size: 18),
const SizedBox(width: 8),
const Text('Chat'),
const Spacer(),
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
size: 20,
),
],
),
),
),
if (_expanded) _buildChatBody(context),
],
);
}
Widget _buildChatBody(BuildContext context) {
final messagesAsync = ref.watch(chatMessagesProvider(widget.threadId));
return messagesAsync.when(
data: (messages) {
return Column(
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: messages.isEmpty
? const Padding(
padding: EdgeInsets.all(12),
child: Text('No messages yet.'),
)
: ListView.builder(
reverse: true,
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[index];
final isMe = msg.senderId == widget.currentUserId;
final sender =
widget.profileById[msg.senderId]?.fullName ??
'Unknown';
return Align(
alignment: isMe
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: isMe
? Theme.of(
context,
).colorScheme.primaryContainer
: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: isMe
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (!isMe)
Text(
sender,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(fontWeight: FontWeight.w600),
),
Text(msg.body),
],
),
),
);
},
),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: TextField(
controller: _msgController,
decoration: const InputDecoration(
hintText: 'Type a message...',
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
onSubmitted: (_) => _send(),
),
),
const SizedBox(width: 4),
IconButton(
onPressed: _send,
icon: const Icon(Icons.send),
iconSize: 20,
),
],
),
],
);
},
loading: () =>
const Center(child: CircularProgressIndicator(strokeWidth: 2)),
error: (e, _) => Text('Chat error: $e'),
);
}
Future<void> _send() async {
final body = _msgController.text.trim();
if (body.isEmpty) return;
_msgController.clear();
await ref
.read(chatControllerProvider)
.sendMessage(threadId: widget.threadId, body: body);
}
}