tasq/lib/screens/workforce/workforce_screen.dart

2631 lines
86 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 '../../providers/notifications_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) {
if (!isAdmin) {
return schedulePanel;
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: schedulePanel),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: Column(
children: [
generatorPanel,
const SizedBox(height: 16),
Expanded(child: swapsPanel),
],
),
),
],
);
}
if (!isAdmin) {
return schedulePanel;
}
return DefaultTabController(
length: 3,
child: Column(
children: [
const SizedBox(height: 8),
const TabBar(
tabs: [
Tab(text: 'Schedule'),
Tab(text: 'Swaps'),
Tab(text: 'Generator'),
],
),
const SizedBox(height: 8),
Expanded(
child: TabBarView(
children: [
schedulePanel,
swapsPanel,
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),
for (int i = 0; i < items.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i * 40),
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _ScheduleTile(
schedule: items[i],
displayName: _scheduleName(
profileById,
items[i],
isAdmin,
rotationConfig,
),
relieverLabels: _relieverLabelsFromIds(
items[i].relieverIds,
profileById,
),
isMine: items[i].userId == currentUserId,
isAdmin: isAdmin,
role: profileById[items[i].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 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 (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');
}
}
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 removedSwapIds = ref.watch(locallyRemovedSwapIdsProvider);
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: (allItems) {
// Immediately exclude locally acted-on swap IDs while waiting for the
// stream to catch up (avoids stale cards flashing back after invalidation).
final items = allItems
.where((s) => !removedSwapIds.contains(s.id))
.toList();
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.read(locallyRemovedSwapIdsProvider.notifier).update((s) => {...s, request.id});
ref.invalidate(swapRequestsProvider);
ref.invalidate(dutySchedulesProvider);
// Send push notifications with date context
final notificationsController = ref.read(notificationsControllerProvider);
final shiftDate = request.shiftStartTime != null
? AppTime.formatDate(request.shiftStartTime!)
: 'the shift';
if (action == 'accepted') {
await notificationsController.sendPush(
userIds: [request.requesterId, request.recipientId],
title: 'Swap approved',
body: 'An admin approved the swap for $shiftDate.',
data: {'type': 'swap_update', 'navigate_to': '/attendance'},
);
} else if (action == 'rejected') {
await notificationsController.sendPush(
userIds: [request.requesterId],
title: 'Swap rejected by admin',
body: 'An admin rejected your swap request for $shiftDate.',
data: {'type': 'swap_update', 'navigate_to': '/attendance'},
);
}
}
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);
}
}