2808 lines
92 KiB
Dart
2808 lines
92 KiB
Dart
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/app_page_header.dart';
|
||
import '../../widgets/app_state_view.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 == 'programmer' || role == 'dispatcher';
|
||
|
||
return ResponsiveBody(
|
||
child: Column(
|
||
children: [
|
||
const AppPageHeader(
|
||
title: 'Workforce',
|
||
subtitle: 'Duty schedules and shift management',
|
||
),
|
||
Expanded(
|
||
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 rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
|
||
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 AppEmptyView(
|
||
icon: Icons.calendar_month_outlined,
|
||
title: 'No schedules yet',
|
||
subtitle:
|
||
'Generated schedules will appear here. Use the Generator tab to create them.',
|
||
);
|
||
}
|
||
|
||
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,
|
||
rotationConfig,
|
||
),
|
||
relieverLabels: _relieverLabelsFromIds(
|
||
schedule.relieverIds,
|
||
profileById,
|
||
),
|
||
isMine: schedule.userId == currentUserId,
|
||
isAdmin: isAdmin,
|
||
role: profileById[schedule.userId]?.role,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
loading: () => const Center(child: CircularProgressIndicator()),
|
||
error: (error, _) => AppErrorView(
|
||
error: error,
|
||
onRetry: () => ref.invalidate(dutySchedulesProvider),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
String _scheduleName(
|
||
Map<String, Profile> profileById,
|
||
DutySchedule schedule,
|
||
bool isAdmin,
|
||
RotationConfig? rotationConfig,
|
||
) {
|
||
final profile = profileById[schedule.userId];
|
||
final name = profile?.fullName.isNotEmpty == true
|
||
? profile!.fullName
|
||
: schedule.userId;
|
||
return '${_shiftLabel(schedule.shiftType, rotationConfig)} · $name';
|
||
}
|
||
|
||
static String _dayOfWeek(DateTime day) {
|
||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||
return days[day.weekday - 1];
|
||
}
|
||
|
||
String _shiftLabel(String value, RotationConfig? config) {
|
||
final configured = config?.shiftTypes.firstWhere(
|
||
(s) => s.id == value,
|
||
orElse: () => ShiftTypeConfig(
|
||
id: value,
|
||
label: value,
|
||
startHour: 0,
|
||
startMinute: 0,
|
||
durationMinutes: 0,
|
||
),
|
||
);
|
||
if (configured != null && configured.label.isNotEmpty) {
|
||
return configured.label;
|
||
}
|
||
|
||
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,
|
||
this.role,
|
||
});
|
||
|
||
final DutySchedule schedule;
|
||
final String displayName;
|
||
final List<String> relieverLabels;
|
||
final bool isMine;
|
||
final bool isAdmin;
|
||
final String? role;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final currentUserId = ref.watch(currentUserIdProvider);
|
||
// Use .select() so this tile only rebuilds when its own swap status changes,
|
||
// not every time any swap in the list is updated.
|
||
final hasRequestedSwap = ref.watch(
|
||
swapRequestsProvider.select(
|
||
(async) => (async.valueOrNull ?? const []).any(
|
||
(swap) =>
|
||
swap.requesterScheduleId == schedule.id &&
|
||
swap.requesterId == currentUserId &&
|
||
swap.status == 'pending',
|
||
),
|
||
),
|
||
);
|
||
final now = AppTime.now();
|
||
final isPast = schedule.startTime.isBefore(now);
|
||
final canRequestSwap = isMine && schedule.status != 'absent' && !isPast;
|
||
|
||
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
|
||
|
||
ShiftTypeConfig? shiftTypeConfig;
|
||
if (rotationConfig != null) {
|
||
try {
|
||
shiftTypeConfig = rotationConfig.shiftTypes.firstWhere(
|
||
(s) => s.id == schedule.shiftType,
|
||
);
|
||
} catch (_) {
|
||
shiftTypeConfig = null;
|
||
}
|
||
}
|
||
|
||
final isInvalidShiftForRole =
|
||
role != null &&
|
||
shiftTypeConfig != null &&
|
||
shiftTypeConfig.allowedRoles.isNotEmpty &&
|
||
!shiftTypeConfig.allowedRoles.contains(role);
|
||
|
||
final isHoliday =
|
||
rotationConfig?.holidays.any(
|
||
(h) =>
|
||
h.date.year == schedule.startTime.year &&
|
||
h.date.month == schedule.startTime.month &&
|
||
h.date.day == schedule.startTime.day,
|
||
) ==
|
||
true;
|
||
|
||
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),
|
||
),
|
||
),
|
||
if (isHoliday) ...[
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.calendar_month,
|
||
size: 16,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
'Holiday',
|
||
style: Theme.of(context).textTheme.bodySmall
|
||
?.copyWith(
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.primary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
if (isInvalidShiftForRole) ...[
|
||
const SizedBox(height: 4),
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.warning_amber,
|
||
size: 16,
|
||
color: Theme.of(context).colorScheme.error,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
'Shift not allowed for role',
|
||
style: Theme.of(context).textTheme.bodySmall
|
||
?.copyWith(
|
||
color: Theme.of(context).colorScheme.error,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
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 rotationConfig = ref.read(rotationConfigProvider).valueOrNull;
|
||
final shiftTypeConfigs =
|
||
rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes;
|
||
|
||
final confirmed = await m3ShowDialog<bool>(
|
||
context: context,
|
||
builder: (dialogContext) {
|
||
return StatefulBuilder(
|
||
builder: (context, setState) {
|
||
final selectedRole = () {
|
||
try {
|
||
return staff.firstWhere((p) => p.id == selectedUserId).role;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}();
|
||
|
||
final allowed = shiftTypeConfigs
|
||
.where(
|
||
(s) =>
|
||
selectedRole == null ||
|
||
s.allowedRoles.isEmpty ||
|
||
s.allowedRoles.contains(selectedRole),
|
||
)
|
||
.toList();
|
||
// Ensure the currently selected shift is always available.
|
||
if (!allowed.any((s) => s.id == selectedShift)) {
|
||
final existing = shiftTypeConfigs.firstWhere(
|
||
(s) => s.id == selectedShift,
|
||
orElse: () => ShiftTypeConfig(
|
||
id: selectedShift,
|
||
label: selectedShift,
|
||
startHour: 0,
|
||
startMinute: 0,
|
||
durationMinutes: 0,
|
||
),
|
||
);
|
||
allowed.add(existing);
|
||
}
|
||
|
||
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: [
|
||
for (final type in allowed)
|
||
DropdownMenuItem(
|
||
value: type.id,
|
||
child: Text(type.label),
|
||
),
|
||
],
|
||
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;
|
||
|
||
var 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));
|
||
}
|
||
|
||
// ensure times are expressed in the app timezone (Asia/Manila) before
|
||
// sending to the backend. previously these were raw local DateTimes which
|
||
// caused off-by-offset errors when the device timezone differed.
|
||
startDateTime = AppTime.toAppTime(startDateTime);
|
||
endDateTime = AppTime.toAppTime(endDateTime);
|
||
|
||
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) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
switch (status) {
|
||
case 'arrival':
|
||
return cs.tertiary;
|
||
case 'late':
|
||
return cs.secondary;
|
||
case 'absent':
|
||
return cs.error;
|
||
default:
|
||
return cs.onSurfaceVariant;
|
||
}
|
||
}
|
||
}
|
||
|
||
class _DraftSchedule {
|
||
_DraftSchedule({
|
||
required this.localId,
|
||
required this.userId,
|
||
required this.shiftType,
|
||
required this.startTime,
|
||
required this.endTime,
|
||
this.isHoliday = false,
|
||
List<String>? relieverIds,
|
||
}) : relieverIds = relieverIds ?? <String>[];
|
||
|
||
final int localId;
|
||
String userId;
|
||
String shiftType;
|
||
DateTime startTime;
|
||
DateTime endTime;
|
||
List<String> relieverIds;
|
||
bool isHoliday;
|
||
}
|
||
|
||
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 shiftTypes =
|
||
rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes;
|
||
final templates = _buildTemplates(schedules, shiftTypes);
|
||
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, rotationConfig);
|
||
|
||
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) {
|
||
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
|
||
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, rotationConfig)} · $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,
|
||
ref.read(rotationConfigProvider).valueOrNull,
|
||
);
|
||
});
|
||
}
|
||
|
||
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,
|
||
ref.read(rotationConfigProvider).valueOrNull,
|
||
);
|
||
});
|
||
}
|
||
|
||
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 == 'programmer' ||
|
||
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,
|
||
List<ShiftTypeConfig> shiftTypes,
|
||
) {
|
||
final templates = <String, _ShiftTemplate>{};
|
||
|
||
// Add configured shift type templates first (source of truth).
|
||
for (final shiftType in shiftTypes) {
|
||
templates[shiftType.id] = _ShiftTemplate(
|
||
startHour: shiftType.startHour,
|
||
startMinute: shiftType.startMinute,
|
||
duration: shiftType.duration,
|
||
);
|
||
}
|
||
|
||
// Fall back to observed schedule patterns for any missing shift types.
|
||
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),
|
||
);
|
||
}
|
||
|
||
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 ?? [];
|
||
final shiftTypes =
|
||
rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes;
|
||
final roleWeeklyHours = rotationConfig?.roleWeeklyHours ?? {};
|
||
final holidayDates = (rotationConfig?.holidays ?? [])
|
||
.map((h) => DateTime(h.date.year, h.date.month, h.date.day))
|
||
.toSet();
|
||
|
||
final userRoles = {for (final p in staff) p.id: p.role};
|
||
|
||
bool isShiftTypeAllowedForRole(String shiftTypeId, String role) {
|
||
final config = shiftTypes.firstWhere(
|
||
(s) => s.id == shiftTypeId,
|
||
orElse: () => ShiftTypeConfig(
|
||
id: shiftTypeId,
|
||
label: shiftTypeId,
|
||
startHour: 0,
|
||
startMinute: 0,
|
||
durationMinutes: 0,
|
||
allowedRoles: const [],
|
||
),
|
||
);
|
||
return config.allowedRoles.isEmpty || config.allowedRoles.contains(role);
|
||
}
|
||
|
||
int minutesInWeekForUser(
|
||
String userId,
|
||
DateTime weekStart,
|
||
DateTime weekEnd,
|
||
) {
|
||
int workMinutesForSchedule(dynamic schedule) {
|
||
final duration = schedule.endTime.difference(schedule.startTime);
|
||
final shiftType = shiftTypes.firstWhere(
|
||
(s) => s.id == schedule.shiftType,
|
||
orElse: () => ShiftTypeConfig(
|
||
id: schedule.shiftType,
|
||
label: schedule.shiftType,
|
||
startHour: 0,
|
||
startMinute: 0,
|
||
durationMinutes: duration.inMinutes,
|
||
),
|
||
);
|
||
final breakMinutes = shiftType.noonBreakMinutes;
|
||
return (duration.inMinutes - breakMinutes)
|
||
.clamp(0, duration.inMinutes)
|
||
.toInt();
|
||
}
|
||
|
||
var total = 0;
|
||
for (final schedule in existing) {
|
||
if (schedule.userId != userId) continue;
|
||
if (schedule.startTime.isBefore(weekStart) ||
|
||
schedule.startTime.isAfter(weekEnd)) {
|
||
continue;
|
||
}
|
||
total += workMinutesForSchedule(schedule);
|
||
}
|
||
for (final item in draft) {
|
||
if (item.userId != userId) continue;
|
||
if (item.startTime.isBefore(weekStart) ||
|
||
item.startTime.isAfter(weekEnd)) {
|
||
continue;
|
||
}
|
||
total += workMinutesForSchedule(item);
|
||
}
|
||
return total;
|
||
}
|
||
|
||
bool canAddShift(
|
||
String userId,
|
||
String shiftTypeId,
|
||
int shiftMinutes,
|
||
DateTime weekStart,
|
||
DateTime weekEnd,
|
||
) {
|
||
final role = userRoles[userId];
|
||
if (role == null) return true;
|
||
if (!isShiftTypeAllowedForRole(shiftTypeId, role)) {
|
||
return false;
|
||
}
|
||
final capHours = roleWeeklyHours[role];
|
||
if (capHours == null || capHours <= 0) return true;
|
||
final capMinutes = capHours * 60;
|
||
final currentMinutes = minutesInWeekForUser(userId, weekStart, weekEnd);
|
||
return currentMinutes + shiftMinutes <= capMinutes;
|
||
}
|
||
|
||
// 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);
|
||
|
||
void tryAddShift(
|
||
String shiftType,
|
||
String userId,
|
||
DateTime day,
|
||
List<String> relieverIds, {
|
||
String? displayShiftType,
|
||
required bool isHoliday,
|
||
}) {
|
||
final template = templates[_normalizeShiftType(shiftType)];
|
||
if (template == null) return;
|
||
final durationMinutes = template.duration.inMinutes;
|
||
if (!canAddShift(
|
||
userId,
|
||
shiftType,
|
||
durationMinutes,
|
||
weekStart,
|
||
weekEnd,
|
||
)) {
|
||
return;
|
||
}
|
||
_tryAddDraft(
|
||
draft,
|
||
existing,
|
||
templates,
|
||
shiftType,
|
||
userId,
|
||
day,
|
||
relieverIds,
|
||
displayShiftType: displayShiftType,
|
||
isHoliday: isHoliday,
|
||
);
|
||
}
|
||
|
||
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);
|
||
final isHoliday = holidayDates.contains(
|
||
DateTime(day.year, day.month, day.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) {
|
||
tryAddShift(
|
||
'normal',
|
||
amUserId,
|
||
day,
|
||
const [],
|
||
isHoliday: isHoliday,
|
||
);
|
||
}
|
||
if (pmUserId != null) {
|
||
tryAddShift(
|
||
'on_call_saturday',
|
||
pmUserId,
|
||
day,
|
||
pmRelievers,
|
||
isHoliday: isHoliday,
|
||
);
|
||
}
|
||
} else {
|
||
// Sunday: PM person gets both normal and weekend on_call
|
||
if (pmUserId != null) {
|
||
tryAddShift(
|
||
'normal',
|
||
pmUserId,
|
||
day,
|
||
const [],
|
||
isHoliday: isHoliday,
|
||
);
|
||
tryAddShift(
|
||
'on_call_sunday',
|
||
pmUserId,
|
||
day,
|
||
pmRelievers,
|
||
isHoliday: isHoliday,
|
||
);
|
||
}
|
||
}
|
||
} 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) {
|
||
tryAddShift(
|
||
'am',
|
||
effectiveAmUserId,
|
||
day,
|
||
const [],
|
||
isHoliday: isHoliday,
|
||
);
|
||
}
|
||
if (pmUserId != null) {
|
||
tryAddShift('pm', pmUserId, day, pmRelievers, isHoliday: isHoliday);
|
||
tryAddShift(
|
||
'on_call',
|
||
pmUserId,
|
||
day,
|
||
pmRelievers,
|
||
isHoliday: isHoliday,
|
||
);
|
||
}
|
||
|
||
// 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';
|
||
tryAddShift(
|
||
normalKey,
|
||
profile.id,
|
||
day,
|
||
const [],
|
||
displayShiftType: 'normal',
|
||
isHoliday: isHoliday,
|
||
);
|
||
}
|
||
|
||
// Admin/Dispatcher always get a role-specific shift (no rotation).
|
||
String shiftTypeForRole(Profile profile) {
|
||
// Exclude IT rotation shift types; those are handled above.
|
||
const itRotationIds = {
|
||
'am',
|
||
'pm',
|
||
'on_call',
|
||
'on_call_saturday',
|
||
'on_call_sunday',
|
||
};
|
||
|
||
final candidates = shiftTypes.where((s) {
|
||
if (itRotationIds.contains(s.id)) return false;
|
||
if (s.allowedRoles.isNotEmpty &&
|
||
!s.allowedRoles.contains(profile.role)) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}).toList();
|
||
|
||
if (candidates.isEmpty) {
|
||
// Fallback to the old "normal" behavior.
|
||
if (dayIsRamadan) {
|
||
return profile.religion == 'islam'
|
||
? 'normal_ramadan_islam'
|
||
: 'normal_ramadan_other';
|
||
}
|
||
return 'normal';
|
||
}
|
||
|
||
if (dayIsRamadan) {
|
||
final r = profile.religion == 'islam'
|
||
? 'ramadan_islam'
|
||
: 'ramadan_other';
|
||
final match = candidates.firstWhere(
|
||
(s) => s.id.contains(r),
|
||
orElse: () => candidates.first,
|
||
);
|
||
return match.id;
|
||
}
|
||
|
||
return candidates.first.id;
|
||
}
|
||
|
||
for (final profile in nonRotating) {
|
||
final shiftKey = shiftTypeForRole(profile);
|
||
tryAddShift(
|
||
shiftKey,
|
||
profile.id,
|
||
day,
|
||
const [],
|
||
displayShiftType: shiftKey,
|
||
isHoliday: isHoliday,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
bool isHoliday = false,
|
||
}) {
|
||
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,
|
||
isHoliday: isHoliday,
|
||
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,
|
||
RotationConfig? rotationConfig,
|
||
) {
|
||
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, rotationConfig)}',
|
||
);
|
||
}
|
||
}
|
||
|
||
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, RotationConfig? rotationConfig) {
|
||
final configured = rotationConfig?.shiftTypes.firstWhere(
|
||
(s) => s.id == value,
|
||
orElse: () => ShiftTypeConfig(
|
||
id: value,
|
||
label: value,
|
||
startHour: 0,
|
||
startMinute: 0,
|
||
durationMinutes: 0,
|
||
),
|
||
);
|
||
if (configured != null && configured.label.isNotEmpty) {
|
||
return configured.label;
|
||
}
|
||
|
||
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 rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
|
||
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, rotationConfig)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)} → ${_shiftLabel(targetSchedule.shiftType, rotationConfig)} · ${AppTime.formatDate(targetSchedule.startTime)} · ${AppTime.formatTime(targetSchedule.startTime)}';
|
||
} else if (requesterSchedule != null) {
|
||
subtitle =
|
||
'${_shiftLabel(requesterSchedule.shiftType, rotationConfig)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)}';
|
||
} else if (item.shiftStartTime != null) {
|
||
subtitle =
|
||
'${_shiftLabel(item.shiftType ?? 'normal', rotationConfig)} · ${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, RotationConfig? rotationConfig) {
|
||
final configured = rotationConfig?.shiftTypes.firstWhere(
|
||
(s) => s.id == value,
|
||
orElse: () => ShiftTypeConfig(
|
||
id: value,
|
||
label: value,
|
||
startHour: 0,
|
||
startMinute: 0,
|
||
durationMinutes: 0,
|
||
),
|
||
);
|
||
if (configured != null && configured.label.isNotEmpty) {
|
||
return configured.label;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|