Added configurable shift types and holiday settings
This commit is contained in:
parent
4eaf9444f0
commit
3af7a1e348
|
|
@ -5,16 +5,23 @@
|
||||||
/// - Ordered list of non-Islam IT Staff IDs for Friday AM rotation
|
/// - Ordered list of non-Islam IT Staff IDs for Friday AM rotation
|
||||||
/// - Set of excluded IT Staff IDs
|
/// - Set of excluded IT Staff IDs
|
||||||
/// - Initial AM and PM duty assignments for the next generated schedule
|
/// - Initial AM and PM duty assignments for the next generated schedule
|
||||||
|
/// - Shift type definitions, role → shift type mappings, weekly hour caps
|
||||||
|
/// - Holiday settings (national / custom)
|
||||||
class RotationConfig {
|
class RotationConfig {
|
||||||
RotationConfig({
|
RotationConfig({
|
||||||
List<String>? rotationOrder,
|
this.rotationOrder = const [],
|
||||||
List<String>? fridayAmOrder,
|
this.fridayAmOrder = const [],
|
||||||
List<String>? excludedStaffIds,
|
this.excludedStaffIds = const [],
|
||||||
this.initialAmStaffId,
|
this.initialAmStaffId,
|
||||||
this.initialPmStaffId,
|
this.initialPmStaffId,
|
||||||
}) : rotationOrder = rotationOrder ?? [],
|
List<ShiftTypeConfig>? shiftTypes,
|
||||||
fridayAmOrder = fridayAmOrder ?? [],
|
Map<String, int>? roleWeeklyHours,
|
||||||
excludedStaffIds = excludedStaffIds ?? [];
|
List<Holiday>? holidays,
|
||||||
|
this.syncPhilippinesHolidays = false,
|
||||||
|
this.holidaysYear,
|
||||||
|
}) : shiftTypes = shiftTypes ?? _defaultShiftTypes(),
|
||||||
|
roleWeeklyHours = roleWeeklyHours ?? {},
|
||||||
|
holidays = holidays ?? [];
|
||||||
|
|
||||||
/// Ordered IDs for standard AM/PM rotation.
|
/// Ordered IDs for standard AM/PM rotation.
|
||||||
final List<String> rotationOrder;
|
final List<String> rotationOrder;
|
||||||
|
|
@ -33,6 +40,21 @@ class RotationConfig {
|
||||||
/// generated schedule. `null` means "continue from last week".
|
/// generated schedule. `null` means "continue from last week".
|
||||||
final String? initialPmStaffId;
|
final String? initialPmStaffId;
|
||||||
|
|
||||||
|
/// Configured shift types with their time definitions and allowed roles.
|
||||||
|
final List<ShiftTypeConfig> shiftTypes;
|
||||||
|
|
||||||
|
/// Weekly hour cap per role (e.g. { 'it_staff': 40 }).
|
||||||
|
final Map<String, int> roleWeeklyHours;
|
||||||
|
|
||||||
|
/// Holidays to render/flag in the schedule. Includes both synced and local.
|
||||||
|
final List<Holiday> holidays;
|
||||||
|
|
||||||
|
/// Whether Philippine national holiday sync is enabled.
|
||||||
|
final bool syncPhilippinesHolidays;
|
||||||
|
|
||||||
|
/// The year used for the last synced holidays. Null means none synced.
|
||||||
|
final int? holidaysYear;
|
||||||
|
|
||||||
factory RotationConfig.fromJson(Map<String, dynamic> json) {
|
factory RotationConfig.fromJson(Map<String, dynamic> json) {
|
||||||
return RotationConfig(
|
return RotationConfig(
|
||||||
rotationOrder: _toStringList(json['rotation_order']),
|
rotationOrder: _toStringList(json['rotation_order']),
|
||||||
|
|
@ -40,6 +62,12 @@ class RotationConfig {
|
||||||
excludedStaffIds: _toStringList(json['excluded_staff_ids']),
|
excludedStaffIds: _toStringList(json['excluded_staff_ids']),
|
||||||
initialAmStaffId: json['initial_am_staff_id'] as String?,
|
initialAmStaffId: json['initial_am_staff_id'] as String?,
|
||||||
initialPmStaffId: json['initial_pm_staff_id'] as String?,
|
initialPmStaffId: json['initial_pm_staff_id'] as String?,
|
||||||
|
shiftTypes: _toShiftTypes(json['shift_types']),
|
||||||
|
roleWeeklyHours: _toStringIntMap(json['role_weekly_hours']),
|
||||||
|
holidays: _toHolidays(json['holidays']),
|
||||||
|
syncPhilippinesHolidays:
|
||||||
|
json['sync_philippines_holidays'] as bool? ?? false,
|
||||||
|
holidaysYear: json['holidays_year'] as int?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,6 +77,11 @@ class RotationConfig {
|
||||||
'excluded_staff_ids': excludedStaffIds,
|
'excluded_staff_ids': excludedStaffIds,
|
||||||
'initial_am_staff_id': initialAmStaffId,
|
'initial_am_staff_id': initialAmStaffId,
|
||||||
'initial_pm_staff_id': initialPmStaffId,
|
'initial_pm_staff_id': initialPmStaffId,
|
||||||
|
'shift_types': shiftTypes.map((e) => e.toJson()).toList(),
|
||||||
|
'role_weekly_hours': roleWeeklyHours,
|
||||||
|
'holidays': holidays.map((e) => e.toJson()).toList(),
|
||||||
|
'sync_philippines_holidays': syncPhilippinesHolidays,
|
||||||
|
'holidays_year': holidaysYear,
|
||||||
};
|
};
|
||||||
|
|
||||||
RotationConfig copyWith({
|
RotationConfig copyWith({
|
||||||
|
|
@ -59,6 +92,11 @@ class RotationConfig {
|
||||||
String? initialPmStaffId,
|
String? initialPmStaffId,
|
||||||
bool clearInitialAm = false,
|
bool clearInitialAm = false,
|
||||||
bool clearInitialPm = false,
|
bool clearInitialPm = false,
|
||||||
|
List<ShiftTypeConfig>? shiftTypes,
|
||||||
|
Map<String, int>? roleWeeklyHours,
|
||||||
|
List<Holiday>? holidays,
|
||||||
|
bool? syncPhilippinesHolidays,
|
||||||
|
int? holidaysYear,
|
||||||
}) {
|
}) {
|
||||||
return RotationConfig(
|
return RotationConfig(
|
||||||
rotationOrder: rotationOrder ?? this.rotationOrder,
|
rotationOrder: rotationOrder ?? this.rotationOrder,
|
||||||
|
|
@ -70,11 +108,224 @@ class RotationConfig {
|
||||||
initialPmStaffId: clearInitialPm
|
initialPmStaffId: clearInitialPm
|
||||||
? null
|
? null
|
||||||
: (initialPmStaffId ?? this.initialPmStaffId),
|
: (initialPmStaffId ?? this.initialPmStaffId),
|
||||||
|
shiftTypes: shiftTypes ?? this.shiftTypes,
|
||||||
|
roleWeeklyHours: roleWeeklyHours ?? this.roleWeeklyHours,
|
||||||
|
holidays: holidays ?? this.holidays,
|
||||||
|
syncPhilippinesHolidays:
|
||||||
|
syncPhilippinesHolidays ?? this.syncPhilippinesHolidays,
|
||||||
|
holidaysYear: holidaysYear ?? this.holidaysYear,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<ShiftTypeConfig> _defaultShiftTypes() {
|
||||||
|
return const [
|
||||||
|
ShiftTypeConfig(
|
||||||
|
id: 'am',
|
||||||
|
label: 'AM Duty',
|
||||||
|
startHour: 7,
|
||||||
|
startMinute: 0,
|
||||||
|
durationMinutes: 8 * 60,
|
||||||
|
allowedRoles: ['it_staff'],
|
||||||
|
),
|
||||||
|
ShiftTypeConfig(
|
||||||
|
id: 'pm',
|
||||||
|
label: 'PM Duty',
|
||||||
|
startHour: 15,
|
||||||
|
startMinute: 0,
|
||||||
|
durationMinutes: 8 * 60,
|
||||||
|
allowedRoles: ['it_staff'],
|
||||||
|
),
|
||||||
|
ShiftTypeConfig(
|
||||||
|
id: 'on_call',
|
||||||
|
label: 'On Call',
|
||||||
|
startHour: 23,
|
||||||
|
startMinute: 0,
|
||||||
|
durationMinutes: 8 * 60,
|
||||||
|
allowedRoles: ['it_staff'],
|
||||||
|
),
|
||||||
|
ShiftTypeConfig(
|
||||||
|
id: 'on_call_saturday',
|
||||||
|
label: 'On Call (Saturday)',
|
||||||
|
startHour: 17,
|
||||||
|
startMinute: 0,
|
||||||
|
durationMinutes: 15 * 60,
|
||||||
|
allowedRoles: ['it_staff'],
|
||||||
|
),
|
||||||
|
ShiftTypeConfig(
|
||||||
|
id: 'on_call_sunday',
|
||||||
|
label: 'On Call (Sunday)',
|
||||||
|
startHour: 17,
|
||||||
|
startMinute: 0,
|
||||||
|
durationMinutes: 14 * 60,
|
||||||
|
allowedRoles: ['it_staff'],
|
||||||
|
),
|
||||||
|
ShiftTypeConfig(
|
||||||
|
id: 'normal',
|
||||||
|
label: 'Normal',
|
||||||
|
startHour: 8,
|
||||||
|
startMinute: 0,
|
||||||
|
durationMinutes: 9 * 60,
|
||||||
|
allowedRoles: ['admin', 'dispatcher', 'programmer', 'it_staff'],
|
||||||
|
),
|
||||||
|
ShiftTypeConfig(
|
||||||
|
id: 'normal_ramadan_islam',
|
||||||
|
label: 'Normal (Ramadan - Islam)',
|
||||||
|
startHour: 8,
|
||||||
|
startMinute: 0,
|
||||||
|
durationMinutes: 8 * 60,
|
||||||
|
allowedRoles: ['admin', 'dispatcher', 'programmer', 'it_staff'],
|
||||||
|
),
|
||||||
|
ShiftTypeConfig(
|
||||||
|
id: 'normal_ramadan_other',
|
||||||
|
label: 'Normal (Ramadan - Other)',
|
||||||
|
startHour: 8,
|
||||||
|
startMinute: 0,
|
||||||
|
durationMinutes: 9 * 60,
|
||||||
|
allowedRoles: ['admin', 'dispatcher', 'programmer', 'it_staff'],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
static List<String> _toStringList(dynamic value) {
|
static List<String> _toStringList(dynamic value) {
|
||||||
if (value is List) return value.map((e) => e.toString()).toList();
|
if (value is List) return value.map((e) => e.toString()).toList();
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<ShiftTypeConfig> _toShiftTypes(dynamic value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(ShiftTypeConfig.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return _defaultShiftTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, int> _toStringIntMap(dynamic value) {
|
||||||
|
if (value is Map) {
|
||||||
|
return value.map((key, val) {
|
||||||
|
final intVal = val is int
|
||||||
|
? val
|
||||||
|
: int.tryParse(val?.toString() ?? '') ?? 0;
|
||||||
|
return MapEntry(key.toString(), intVal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Holiday> _toHolidays(dynamic value) {
|
||||||
|
if (value is List) {
|
||||||
|
return value
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(Holiday.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A configurable shift type used by the schedule generator.
|
||||||
|
class ShiftTypeConfig {
|
||||||
|
const ShiftTypeConfig({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.startHour,
|
||||||
|
required this.startMinute,
|
||||||
|
required this.durationMinutes,
|
||||||
|
this.noonBreakMinutes = 60,
|
||||||
|
this.allowedRoles = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Unique shift type ID (e.g. "am", "pm", "on_call").
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Human-readable label (e.g. "AM Duty").
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Hour of day (0-23) when this shift begins.
|
||||||
|
final int startHour;
|
||||||
|
|
||||||
|
/// Minute (0-59) when this shift begins.
|
||||||
|
final int startMinute;
|
||||||
|
|
||||||
|
/// Duration in minutes.
|
||||||
|
final int durationMinutes;
|
||||||
|
|
||||||
|
/// Minutes reserved for a noon/meal break during the shift.
|
||||||
|
final int noonBreakMinutes;
|
||||||
|
|
||||||
|
/// Roles that are allowed to be assigned this shift type.
|
||||||
|
final List<String> allowedRoles;
|
||||||
|
|
||||||
|
Duration get duration => Duration(minutes: durationMinutes);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'label': label,
|
||||||
|
'start_hour': startHour,
|
||||||
|
'start_minute': startMinute,
|
||||||
|
'duration_minutes': durationMinutes,
|
||||||
|
'noon_break_minutes': noonBreakMinutes,
|
||||||
|
'allowed_roles': allowedRoles,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ShiftTypeConfig.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ShiftTypeConfig(
|
||||||
|
id: json['id'] as String,
|
||||||
|
label: json['label'] as String? ?? json['id'] as String,
|
||||||
|
startHour: json['start_hour'] is int
|
||||||
|
? json['start_hour'] as int
|
||||||
|
: int.tryParse(json['start_hour']?.toString() ?? '') ?? 0,
|
||||||
|
startMinute: json['start_minute'] is int
|
||||||
|
? json['start_minute'] as int
|
||||||
|
: int.tryParse(json['start_minute']?.toString() ?? '') ?? 0,
|
||||||
|
durationMinutes: json['duration_minutes'] is int
|
||||||
|
? json['duration_minutes'] as int
|
||||||
|
: int.tryParse(json['duration_minutes']?.toString() ?? '') ?? 0,
|
||||||
|
noonBreakMinutes: json['noon_break_minutes'] is int
|
||||||
|
? json['noon_break_minutes'] as int
|
||||||
|
: int.tryParse(json['noon_break_minutes']?.toString() ?? '') ?? 60,
|
||||||
|
allowedRoles: json['allowed_roles'] is List
|
||||||
|
? (json['allowed_roles'] as List).map((e) => e.toString()).toList()
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A holiday used to flag schedule days.
|
||||||
|
class Holiday {
|
||||||
|
Holiday({required this.date, required this.name, this.source});
|
||||||
|
|
||||||
|
/// Date of the holiday (only date portion is used).
|
||||||
|
final DateTime date;
|
||||||
|
|
||||||
|
/// Holiday name (e.g. "Independence Day").
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Optional source (e.g. "nager") to distinguish synced vs custom.
|
||||||
|
final String? source;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'date': date.toIso8601String(),
|
||||||
|
'name': name,
|
||||||
|
'source': source,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory Holiday.fromJson(Map<String, dynamic> json) {
|
||||||
|
final dateRaw = json['date'];
|
||||||
|
DateTime date;
|
||||||
|
if (dateRaw is String) {
|
||||||
|
date = DateTime.parse(dateRaw);
|
||||||
|
} else if (dateRaw is DateTime) {
|
||||||
|
date = dateRaw;
|
||||||
|
} else {
|
||||||
|
date = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Holiday(
|
||||||
|
date: DateTime(date.year, date.month, date.day),
|
||||||
|
name: json['name'] as String? ?? 'Holiday',
|
||||||
|
source: json['source'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,34 @@ class RotationConfigController {
|
||||||
});
|
});
|
||||||
_ref.invalidate(rotationConfigProvider);
|
_ref.invalidate(rotationConfigProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateShiftTypes(List<ShiftTypeConfig> shiftTypes) async {
|
||||||
|
final config = await _ref.read(rotationConfigProvider.future);
|
||||||
|
await save(config.copyWith(shiftTypes: shiftTypes));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateRoleWeeklyHours(Map<String, int> roleWeeklyHours) async {
|
||||||
|
final config = await _ref.read(rotationConfigProvider.future);
|
||||||
|
await save(config.copyWith(roleWeeklyHours: roleWeeklyHours));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateHolidays(List<Holiday> holidays) async {
|
||||||
|
final config = await _ref.read(rotationConfigProvider.future);
|
||||||
|
await save(config.copyWith(holidays: holidays));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setHolidaySync({
|
||||||
|
required bool enabled,
|
||||||
|
required int year,
|
||||||
|
required List<Holiday> holidays,
|
||||||
|
}) async {
|
||||||
|
final config = await _ref.read(rotationConfigProvider.future);
|
||||||
|
await save(
|
||||||
|
config.copyWith(
|
||||||
|
syncPhilippinesHolidays: enabled,
|
||||||
|
holidaysYear: year,
|
||||||
|
holidays: holidays,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/rotation_config_provider.dart';
|
import '../../providers/rotation_config_provider.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../theme/m3_motion.dart';
|
import '../../theme/m3_motion.dart';
|
||||||
|
import '../../services/holidays_service.dart';
|
||||||
|
import '../../utils/app_time.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
|
||||||
/// Opens rotation settings as a dialog (desktop/tablet) or bottom sheet
|
/// Opens rotation settings as a dialog (desktop/tablet) or bottom sheet
|
||||||
|
|
@ -53,7 +55,7 @@ class _RotationSettingsDialogState
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabCtrl = TabController(length: 4, vsync: this);
|
_tabCtrl = TabController(length: 6, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -83,6 +85,8 @@ class _RotationSettingsDialogState
|
||||||
Tab(text: 'Friday AM'),
|
Tab(text: 'Friday AM'),
|
||||||
Tab(text: 'Excluded'),
|
Tab(text: 'Excluded'),
|
||||||
Tab(text: 'Initial Duty'),
|
Tab(text: 'Initial Duty'),
|
||||||
|
Tab(text: 'Shift Types'),
|
||||||
|
Tab(text: 'Holidays'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Flexible(
|
Flexible(
|
||||||
|
|
@ -93,6 +97,8 @@ class _RotationSettingsDialogState
|
||||||
_FridayAmTab(),
|
_FridayAmTab(),
|
||||||
_ExcludedTab(),
|
_ExcludedTab(),
|
||||||
_InitialDutyTab(),
|
_InitialDutyTab(),
|
||||||
|
_ShiftTypesTab(),
|
||||||
|
_HolidaySettingsTab(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -148,7 +154,7 @@ class _RotationSettingsSheetState extends ConsumerState<_RotationSettingsSheet>
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabCtrl = TabController(length: 4, vsync: this);
|
_tabCtrl = TabController(length: 6, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -192,6 +198,8 @@ class _RotationSettingsSheetState extends ConsumerState<_RotationSettingsSheet>
|
||||||
Tab(text: 'Friday AM'),
|
Tab(text: 'Friday AM'),
|
||||||
Tab(text: 'Excluded'),
|
Tab(text: 'Excluded'),
|
||||||
Tab(text: 'Initial Duty'),
|
Tab(text: 'Initial Duty'),
|
||||||
|
Tab(text: 'Shift Types'),
|
||||||
|
Tab(text: 'Holidays'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -202,6 +210,8 @@ class _RotationSettingsSheetState extends ConsumerState<_RotationSettingsSheet>
|
||||||
_FridayAmTab(),
|
_FridayAmTab(),
|
||||||
_ExcludedTab(),
|
_ExcludedTab(),
|
||||||
_InitialDutyTab(),
|
_InitialDutyTab(),
|
||||||
|
_ShiftTypesTab(),
|
||||||
|
_HolidaySettingsTab(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -795,20 +805,643 @@ class _SaveButton extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: SizedBox(
|
child: FilledButton.icon(
|
||||||
width: double.infinity,
|
onPressed: saving ? null : onSave,
|
||||||
child: FilledButton.icon(
|
icon: saving
|
||||||
onPressed: saving ? null : onSave,
|
? const SizedBox(
|
||||||
icon: saving
|
height: 18,
|
||||||
? const SizedBox(
|
width: 18,
|
||||||
height: 18,
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
width: 18,
|
)
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
: const Icon(Icons.save_outlined),
|
||||||
)
|
label: const Text('Save'),
|
||||||
: const Icon(Icons.save_outlined),
|
|
||||||
label: const Text('Save'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Tab 5: Shift Types (configurable shift type definitions & role mapping)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
class _ShiftTypesTab extends ConsumerStatefulWidget {
|
||||||
|
const _ShiftTypesTab();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_ShiftTypesTab> createState() => _ShiftTypesTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShiftTypesTabState extends ConsumerState<_ShiftTypesTab> {
|
||||||
|
List<ShiftTypeConfig>? _shiftTypes;
|
||||||
|
Map<String, int> _weeklyHours = {};
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
static const _knownRoles = [
|
||||||
|
'it_staff',
|
||||||
|
'admin',
|
||||||
|
'dispatcher',
|
||||||
|
'programmer',
|
||||||
|
'standard',
|
||||||
|
];
|
||||||
|
|
||||||
|
void _ensureLoaded() {
|
||||||
|
if (_shiftTypes != null) return;
|
||||||
|
final config = ref.read(rotationConfigProvider).valueOrNull;
|
||||||
|
|
||||||
|
// The default rotation config uses const shift type definitions.
|
||||||
|
// Make a mutable copy so the UI can edit allowedRoles safely.
|
||||||
|
final baseShiftTypes = config?.shiftTypes ?? RotationConfig().shiftTypes;
|
||||||
|
_shiftTypes = baseShiftTypes
|
||||||
|
.map(
|
||||||
|
(s) => ShiftTypeConfig(
|
||||||
|
id: s.id,
|
||||||
|
label: s.label,
|
||||||
|
startHour: s.startHour,
|
||||||
|
startMinute: s.startMinute,
|
||||||
|
durationMinutes: s.durationMinutes,
|
||||||
|
allowedRoles: List<String>.from(s.allowedRoles),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_weeklyHours = config?.roleWeeklyHours ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final configAsync = ref.watch(rotationConfigProvider);
|
||||||
|
if (configAsync.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureLoaded();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Weekly hours per role (hard cap). Leave blank for no limit.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: [
|
||||||
|
for (final role in _knownRoles)
|
||||||
|
SizedBox(
|
||||||
|
width: 140,
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: _weeklyHours[role]?.toString() ?? '',
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: role,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (value) {
|
||||||
|
final hours = int.tryParse(value);
|
||||||
|
setState(() {
|
||||||
|
if (hours != null) {
|
||||||
|
_weeklyHours[role] = hours;
|
||||||
|
} else {
|
||||||
|
_weeklyHours.remove(role);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _shiftTypes!.length,
|
||||||
|
separatorBuilder: (context, index) => const Divider(),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final shift = _shiftTypes![index];
|
||||||
|
final startTime = TimeOfDay(
|
||||||
|
hour: shift.startHour,
|
||||||
|
minute: shift.startMinute,
|
||||||
|
);
|
||||||
|
return ExpansionTile(
|
||||||
|
key: ValueKey(shift.id),
|
||||||
|
title: Text(shift.label),
|
||||||
|
subtitle: Text('ID: ${shift.id}'),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Start: ${startTime.format(context)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Duration: ${shift.durationMinutes ~/ 60}h ${shift.durationMinutes % 60}m',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Allowed roles',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final role in _knownRoles)
|
||||||
|
FilterChip(
|
||||||
|
label: Text(role),
|
||||||
|
selected: shift.allowedRoles.contains(role),
|
||||||
|
onSelected: (selected) {
|
||||||
|
setState(() {
|
||||||
|
if (selected) {
|
||||||
|
shift.allowedRoles.add(role);
|
||||||
|
} else {
|
||||||
|
shift.allowedRoles.remove(role);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _editShiftType(context, index),
|
||||||
|
icon: const Icon(Icons.edit, size: 18),
|
||||||
|
label: const Text('Edit'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _removeShiftType(index),
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 18),
|
||||||
|
label: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _addShiftType,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add shift type'),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_SaveButton(saving: _saving, onSave: () => _save(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addShiftType() async {
|
||||||
|
final newShift = ShiftTypeConfig(
|
||||||
|
id: 'new_shift_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
label: 'New Shift',
|
||||||
|
startHour: 8,
|
||||||
|
startMinute: 0,
|
||||||
|
durationMinutes: 480,
|
||||||
|
noonBreakMinutes: 60,
|
||||||
|
allowedRoles: <String>[],
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_shiftTypes = [...?_shiftTypes, newShift];
|
||||||
|
});
|
||||||
|
await _editShiftType(context, _shiftTypes!.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _removeShiftType(int index) async {
|
||||||
|
setState(() {
|
||||||
|
_shiftTypes = [...?_shiftTypes]..removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editShiftType(BuildContext context, int index) async {
|
||||||
|
final shift = _shiftTypes![index];
|
||||||
|
var label = shift.label;
|
||||||
|
var start = TimeOfDay(hour: shift.startHour, minute: shift.startMinute);
|
||||||
|
var durationMinutes = shift.durationMinutes;
|
||||||
|
var noonBreakMinutes = shift.noonBreakMinutes;
|
||||||
|
|
||||||
|
final confirmed = await m3ShowDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: AppSurfaces.of(context).dialogShape,
|
||||||
|
title: const Text('Edit Shift Type'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
initialValue: label,
|
||||||
|
decoration: const InputDecoration(labelText: 'Label'),
|
||||||
|
onChanged: (v) => setState(() => label = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
InkWell(
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: start,
|
||||||
|
);
|
||||||
|
if (picked != null) setState(() => start = picked);
|
||||||
|
},
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Start time',
|
||||||
|
),
|
||||||
|
child: Text(start.format(context)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: (durationMinutes ~/ 60).toString(),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Hours (total)',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (v) {
|
||||||
|
final h = int.tryParse(v) ?? 0;
|
||||||
|
setState(() {
|
||||||
|
durationMinutes =
|
||||||
|
h * 60 + (durationMinutes % 60);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: (durationMinutes % 60).toString(),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Minutes',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (v) {
|
||||||
|
final m = int.tryParse(v) ?? 0;
|
||||||
|
setState(() {
|
||||||
|
durationMinutes =
|
||||||
|
(durationMinutes ~/ 60) * 60 + m;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
initialValue: noonBreakMinutes.toString(),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Noon break (minutes)',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (v) {
|
||||||
|
final m = int.tryParse(v) ?? 0;
|
||||||
|
setState(() {
|
||||||
|
noonBreakMinutes = m;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_shiftTypes = [...?_shiftTypes];
|
||||||
|
_shiftTypes![index] = ShiftTypeConfig(
|
||||||
|
id: shift.id,
|
||||||
|
label: label,
|
||||||
|
startHour: start.hour,
|
||||||
|
startMinute: start.minute,
|
||||||
|
durationMinutes: durationMinutes,
|
||||||
|
noonBreakMinutes: noonBreakMinutes,
|
||||||
|
allowedRoles: shift.allowedRoles,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save(BuildContext context) async {
|
||||||
|
final ctx = context;
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(rotationConfigControllerProvider)
|
||||||
|
.updateShiftTypes(_shiftTypes!);
|
||||||
|
await ref
|
||||||
|
.read(rotationConfigControllerProvider)
|
||||||
|
.updateRoleWeeklyHours(_weeklyHours);
|
||||||
|
if (mounted) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
showSuccessSnackBar(ctx, 'Shift types saved.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
showErrorSnackBar(ctx, 'Save failed: $e');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Tab 6: Holiday settings (sync + custom holidays)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
class _HolidaySettingsTab extends ConsumerStatefulWidget {
|
||||||
|
const _HolidaySettingsTab();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_HolidaySettingsTab> createState() =>
|
||||||
|
_HolidaySettingsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HolidaySettingsTabState extends ConsumerState<_HolidaySettingsTab> {
|
||||||
|
bool _syncEnabled = false;
|
||||||
|
int _year = DateTime.now().year;
|
||||||
|
bool _saving = false;
|
||||||
|
List<Holiday> _holidays = [];
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
void _ensureLoaded() {
|
||||||
|
if (_loaded) return;
|
||||||
|
final config = ref.read(rotationConfigProvider).valueOrNull;
|
||||||
|
_holidays = config?.holidays ?? [];
|
||||||
|
_syncEnabled = config?.syncPhilippinesHolidays ?? false;
|
||||||
|
_year = config?.holidaysYear ?? DateTime.now().year;
|
||||||
|
_loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final configAsync = ref.watch(rotationConfigProvider);
|
||||||
|
if (configAsync.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureLoaded();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Holidays are used to flag shifts that occur on special dates. '
|
||||||
|
'You can sync Philippine national holidays or add custom dates.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SwitchListTile(
|
||||||
|
value: _syncEnabled,
|
||||||
|
title: const Text('Sync Philippine holidays'),
|
||||||
|
onChanged: (value) => setState(() {
|
||||||
|
_syncEnabled = value;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: TextFormField(
|
||||||
|
initialValue: _year.toString(),
|
||||||
|
decoration: const InputDecoration(labelText: 'Year'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (value) {
|
||||||
|
final parsed = int.tryParse(value);
|
||||||
|
if (parsed != null) {
|
||||||
|
setState(() => _year = parsed);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _syncEnabled ? _syncHolidays : null,
|
||||||
|
child: const Text('Sync now'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _addCustomHoliday,
|
||||||
|
child: const Text('Add custom holiday'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _holidays.length,
|
||||||
|
separatorBuilder: (context, index) => const Divider(),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final holiday = _holidays[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(holiday.name),
|
||||||
|
subtitle: Text(AppTime.formatDate(holiday.date)),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: () => _removeHoliday(index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_SaveButton(saving: _saving, onSave: () => _save(context)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncHolidays() async {
|
||||||
|
final ctx = context;
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
final holidays = await HolidaysService.fetchPhilippinesHolidays(_year);
|
||||||
|
setState(() {
|
||||||
|
_holidays = holidays;
|
||||||
|
});
|
||||||
|
await ref
|
||||||
|
.read(rotationConfigControllerProvider)
|
||||||
|
.setHolidaySync(
|
||||||
|
enabled: _syncEnabled,
|
||||||
|
year: _year,
|
||||||
|
holidays: _holidays,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
showSuccessSnackBar(ctx, 'Holidays synced for $_year.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
showErrorSnackBar(ctx, 'Sync failed: $e');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addCustomHoliday() async {
|
||||||
|
DateTime selectedDate = DateTime.now();
|
||||||
|
String name = '';
|
||||||
|
|
||||||
|
final confirmed = await m3ShowDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: AppSurfaces.of(context).dialogShape,
|
||||||
|
title: const Text('Add custom holiday'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: selectedDate,
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
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),
|
||||||
|
TextFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Name'),
|
||||||
|
onChanged: (value) => setState(() => name = value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || name.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_holidays = [
|
||||||
|
..._holidays,
|
||||||
|
Holiday(date: selectedDate, name: name, source: 'custom'),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeHoliday(int index) {
|
||||||
|
setState(() {
|
||||||
|
_holidays = [..._holidays]..removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save(BuildContext context) async {
|
||||||
|
final ctx = context;
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(rotationConfigControllerProvider)
|
||||||
|
.setHolidaySync(
|
||||||
|
enabled: _syncEnabled,
|
||||||
|
year: _year,
|
||||||
|
holidays: _holidays,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
showSuccessSnackBar(ctx, 'Holidays saved.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
showErrorSnackBar(ctx, 'Save failed: $e');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
||||||
final profilesAsync = ref.watch(profilesProvider);
|
final profilesAsync = ref.watch(profilesProvider);
|
||||||
|
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
|
||||||
final currentUserId = ref.watch(currentUserIdProvider);
|
final currentUserId = ref.watch(currentUserIdProvider);
|
||||||
final showPast = ref.watch(showPastSchedulesProvider);
|
final showPast = ref.watch(showPastSchedulesProvider);
|
||||||
|
|
||||||
|
|
@ -183,6 +184,7 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
profileById,
|
profileById,
|
||||||
schedule,
|
schedule,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
rotationConfig,
|
||||||
),
|
),
|
||||||
relieverLabels: _relieverLabelsFromIds(
|
relieverLabels: _relieverLabelsFromIds(
|
||||||
schedule.relieverIds,
|
schedule.relieverIds,
|
||||||
|
|
@ -211,12 +213,13 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
Map<String, Profile> profileById,
|
Map<String, Profile> profileById,
|
||||||
DutySchedule schedule,
|
DutySchedule schedule,
|
||||||
bool isAdmin,
|
bool isAdmin,
|
||||||
|
RotationConfig? rotationConfig,
|
||||||
) {
|
) {
|
||||||
final profile = profileById[schedule.userId];
|
final profile = profileById[schedule.userId];
|
||||||
final name = profile?.fullName.isNotEmpty == true
|
final name = profile?.fullName.isNotEmpty == true
|
||||||
? profile!.fullName
|
? profile!.fullName
|
||||||
: schedule.userId;
|
: schedule.userId;
|
||||||
return '${_shiftLabel(schedule.shiftType)} · $name';
|
return '${_shiftLabel(schedule.shiftType, rotationConfig)} · $name';
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _dayOfWeek(DateTime day) {
|
static String _dayOfWeek(DateTime day) {
|
||||||
|
|
@ -224,7 +227,21 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
return days[day.weekday - 1];
|
return days[day.weekday - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
String _shiftLabel(String value) {
|
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) {
|
switch (value) {
|
||||||
case 'am':
|
case 'am':
|
||||||
return 'AM Duty';
|
return 'AM Duty';
|
||||||
|
|
@ -285,6 +302,42 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
final canRequestSwap = isMine && schedule.status != 'absent' && !isPast;
|
final canRequestSwap = isMine && schedule.status != 'absent' && !isPast;
|
||||||
|
|
||||||
|
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
|
||||||
|
Profile? profile;
|
||||||
|
try {
|
||||||
|
profile = profiles.firstWhere((p) => p.id == schedule.userId);
|
||||||
|
} catch (_) {
|
||||||
|
profile = null;
|
||||||
|
}
|
||||||
|
final role = profile?.role;
|
||||||
|
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(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
|
@ -314,6 +367,48 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
color: _statusColor(context, schedule.status),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -389,11 +484,46 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
var startTime = TimeOfDay.fromDateTime(schedule.startTime);
|
var startTime = TimeOfDay.fromDateTime(schedule.startTime);
|
||||||
var endTime = TimeOfDay.fromDateTime(schedule.endTime);
|
var endTime = TimeOfDay.fromDateTime(schedule.endTime);
|
||||||
|
|
||||||
|
final rotationConfig = ref.read(rotationConfigProvider).valueOrNull;
|
||||||
|
final shiftTypeConfigs =
|
||||||
|
rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes;
|
||||||
|
|
||||||
final confirmed = await m3ShowDialog<bool>(
|
final confirmed = await m3ShowDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setState) {
|
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(
|
return AlertDialog(
|
||||||
shape: AppSurfaces.of(context).dialogShape,
|
shape: AppSurfaces.of(context).dialogShape,
|
||||||
title: const Text('Edit Schedule'),
|
title: const Text('Edit Schedule'),
|
||||||
|
|
@ -420,17 +550,12 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: selectedShift,
|
initialValue: selectedShift,
|
||||||
items: const [
|
items: [
|
||||||
DropdownMenuItem(value: 'am', child: Text('AM Duty')),
|
for (final type in allowed)
|
||||||
DropdownMenuItem(value: 'pm', child: Text('PM Duty')),
|
DropdownMenuItem(
|
||||||
DropdownMenuItem(
|
value: type.id,
|
||||||
value: 'on_call',
|
child: Text(type.label),
|
||||||
child: Text('On Call'),
|
),
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 'normal',
|
|
||||||
child: Text('Normal'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
if (v != null) setState(() => selectedShift = v);
|
if (v != null) setState(() => selectedShift = v);
|
||||||
|
|
@ -784,6 +909,7 @@ class _DraftSchedule {
|
||||||
required this.shiftType,
|
required this.shiftType,
|
||||||
required this.startTime,
|
required this.startTime,
|
||||||
required this.endTime,
|
required this.endTime,
|
||||||
|
this.isHoliday = false,
|
||||||
List<String>? relieverIds,
|
List<String>? relieverIds,
|
||||||
}) : relieverIds = relieverIds ?? <String>[];
|
}) : relieverIds = relieverIds ?? <String>[];
|
||||||
|
|
||||||
|
|
@ -793,6 +919,7 @@ class _DraftSchedule {
|
||||||
DateTime startTime;
|
DateTime startTime;
|
||||||
DateTime endTime;
|
DateTime endTime;
|
||||||
List<String> relieverIds;
|
List<String> relieverIds;
|
||||||
|
bool isHoliday;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RotationEntry {
|
class _RotationEntry {
|
||||||
|
|
@ -1027,7 +1154,9 @@ class _ScheduleGeneratorPanelState
|
||||||
|
|
||||||
final rotationConfig = ref.read(rotationConfigProvider).valueOrNull;
|
final rotationConfig = ref.read(rotationConfigProvider).valueOrNull;
|
||||||
final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? [];
|
final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? [];
|
||||||
final templates = _buildTemplates(schedules);
|
final shiftTypes =
|
||||||
|
rotationConfig?.shiftTypes ?? RotationConfig().shiftTypes;
|
||||||
|
final templates = _buildTemplates(schedules, shiftTypes);
|
||||||
final generated = _generateDrafts(
|
final generated = _generateDrafts(
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
|
|
@ -1037,7 +1166,7 @@ class _ScheduleGeneratorPanelState
|
||||||
rotationConfig: rotationConfig,
|
rotationConfig: rotationConfig,
|
||||||
);
|
);
|
||||||
generated.sort((a, b) => a.startTime.compareTo(b.startTime));
|
generated.sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||||
final warnings = _buildWarnings(start, end, generated);
|
final warnings = _buildWarnings(start, end, generated, rotationConfig);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -1123,6 +1252,7 @@ class _ScheduleGeneratorPanelState
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDraftList(BuildContext context) {
|
Widget _buildDraftList(BuildContext context) {
|
||||||
|
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
primary: false,
|
primary: false,
|
||||||
itemCount: _draftSchedules.length,
|
itemCount: _draftSchedules.length,
|
||||||
|
|
@ -1153,7 +1283,7 @@ class _ScheduleGeneratorPanelState
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${_shiftLabel(draft.shiftType)} · $userLabel',
|
'${_shiftLabel(draft.shiftType, rotationConfig)} · $userLabel',
|
||||||
style: Theme.of(context).textTheme.bodyMedium
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
?.copyWith(fontWeight: FontWeight.w600),
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
|
|
@ -1219,6 +1349,7 @@ class _ScheduleGeneratorPanelState
|
||||||
_startDate ?? AppTime.now(),
|
_startDate ?? AppTime.now(),
|
||||||
_endDate ?? AppTime.now(),
|
_endDate ?? AppTime.now(),
|
||||||
_draftSchedules,
|
_draftSchedules,
|
||||||
|
ref.read(rotationConfigProvider).valueOrNull,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1400,6 +1531,7 @@ class _ScheduleGeneratorPanelState
|
||||||
_startDate ?? result.startTime,
|
_startDate ?? result.startTime,
|
||||||
_endDate ?? result.endTime,
|
_endDate ?? result.endTime,
|
||||||
_draftSchedules,
|
_draftSchedules,
|
||||||
|
ref.read(rotationConfigProvider).valueOrNull,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1506,8 +1638,22 @@ class _ScheduleGeneratorPanelState
|
||||||
return {for (final profile in profiles) profile.id: profile};
|
return {for (final profile in profiles) profile.id: profile};
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, _ShiftTemplate> _buildTemplates(List<DutySchedule> schedules) {
|
Map<String, _ShiftTemplate> _buildTemplates(
|
||||||
|
List<DutySchedule> schedules,
|
||||||
|
List<ShiftTypeConfig> shiftTypes,
|
||||||
|
) {
|
||||||
final templates = <String, _ShiftTemplate>{};
|
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) {
|
for (final schedule in schedules) {
|
||||||
final key = _normalizeShiftType(schedule.shiftType);
|
final key = _normalizeShiftType(schedule.shiftType);
|
||||||
if (templates.containsKey(key)) continue;
|
if (templates.containsKey(key)) continue;
|
||||||
|
|
@ -1522,51 +1668,7 @@ class _ScheduleGeneratorPanelState
|
||||||
duration: end.difference(start),
|
duration: end.difference(start),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
templates['am'] = _ShiftTemplate(
|
|
||||||
startHour: 7,
|
|
||||||
startMinute: 0,
|
|
||||||
duration: const Duration(hours: 8),
|
|
||||||
);
|
|
||||||
templates['pm'] = _ShiftTemplate(
|
|
||||||
startHour: 15,
|
|
||||||
startMinute: 0,
|
|
||||||
duration: const Duration(hours: 8),
|
|
||||||
);
|
|
||||||
templates['on_call'] = _ShiftTemplate(
|
|
||||||
startHour: 23,
|
|
||||||
startMinute: 0,
|
|
||||||
duration: const Duration(hours: 8),
|
|
||||||
);
|
|
||||||
// Weekend Saturday on_call: 5PM (17:00) to 8AM (08:00) next day = 15 hours
|
|
||||||
templates['on_call_saturday'] = _ShiftTemplate(
|
|
||||||
startHour: 17,
|
|
||||||
startMinute: 0,
|
|
||||||
duration: const Duration(hours: 15),
|
|
||||||
);
|
|
||||||
// Weekend Sunday on_call: 5PM (17:00) to 7AM (07:00) next day = 14 hours
|
|
||||||
templates['on_call_sunday'] = _ShiftTemplate(
|
|
||||||
startHour: 17,
|
|
||||||
startMinute: 0,
|
|
||||||
duration: const Duration(hours: 14),
|
|
||||||
);
|
|
||||||
// Default normal shift (8am-5pm = 9 hours)
|
|
||||||
templates['normal'] = _ShiftTemplate(
|
|
||||||
startHour: 8,
|
|
||||||
startMinute: 0,
|
|
||||||
duration: const Duration(hours: 9),
|
|
||||||
);
|
|
||||||
// Islam Ramadan normal shift (8am-4pm = 8 hours)
|
|
||||||
templates['normal_ramadan_islam'] = _ShiftTemplate(
|
|
||||||
startHour: 8,
|
|
||||||
startMinute: 0,
|
|
||||||
duration: const Duration(hours: 8),
|
|
||||||
);
|
|
||||||
// Non-Islam Ramadan normal shift (8am-5pm = 9 hours, same as default)
|
|
||||||
templates['normal_ramadan_other'] = _ShiftTemplate(
|
|
||||||
startHour: 8,
|
|
||||||
startMinute: 0,
|
|
||||||
duration: const Duration(hours: 9),
|
|
||||||
);
|
|
||||||
return templates;
|
return templates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1584,6 +1686,91 @@ class _ScheduleGeneratorPanelState
|
||||||
final existing = schedules;
|
final existing = schedules;
|
||||||
|
|
||||||
final excludedIds = rotationConfig?.excludedStaffIds ?? [];
|
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)
|
// Only IT Staff rotate through AM/PM/on_call shifts (minus excluded)
|
||||||
final allItStaff = staff.where((p) => p.role == 'it_staff').toList();
|
final allItStaff = staff.where((p) => p.role == 'it_staff').toList();
|
||||||
|
|
@ -1706,6 +1893,39 @@ class _ScheduleGeneratorPanelState
|
||||||
: itStaff[pmBaseIndex % itStaff.length].id;
|
: itStaff[pmBaseIndex % itStaff.length].id;
|
||||||
final pmRelievers = _buildRelievers(pmBaseIndex, itStaff);
|
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 (
|
for (
|
||||||
var day = weekStart;
|
var day = weekStart;
|
||||||
!day.isAfter(weekEnd);
|
!day.isAfter(weekEnd);
|
||||||
|
|
@ -1717,53 +1937,48 @@ class _ScheduleGeneratorPanelState
|
||||||
final isWeekend =
|
final isWeekend =
|
||||||
day.weekday == DateTime.saturday || day.weekday == DateTime.sunday;
|
day.weekday == DateTime.saturday || day.weekday == DateTime.sunday;
|
||||||
final dayIsRamadan = isApproximateRamadan(day);
|
final dayIsRamadan = isApproximateRamadan(day);
|
||||||
|
final isHoliday = holidayDates.contains(
|
||||||
|
DateTime(day.year, day.month, day.day),
|
||||||
|
);
|
||||||
|
|
||||||
if (isWeekend) {
|
if (isWeekend) {
|
||||||
final isSaturday = day.weekday == DateTime.saturday;
|
final isSaturday = day.weekday == DateTime.saturday;
|
||||||
if (isSaturday) {
|
if (isSaturday) {
|
||||||
// Saturday: AM person gets normal shift, PM person gets weekend on_call
|
// Saturday: AM person gets normal shift, PM person gets weekend on_call
|
||||||
if (amUserId != null) {
|
if (amUserId != null) {
|
||||||
_tryAddDraft(
|
tryAddShift(
|
||||||
draft,
|
|
||||||
existing,
|
|
||||||
templates,
|
|
||||||
'normal',
|
'normal',
|
||||||
amUserId,
|
amUserId,
|
||||||
day,
|
day,
|
||||||
const [],
|
const [],
|
||||||
|
isHoliday: isHoliday,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (pmUserId != null) {
|
if (pmUserId != null) {
|
||||||
_tryAddDraft(
|
tryAddShift(
|
||||||
draft,
|
|
||||||
existing,
|
|
||||||
templates,
|
|
||||||
'on_call_saturday',
|
'on_call_saturday',
|
||||||
pmUserId,
|
pmUserId,
|
||||||
day,
|
day,
|
||||||
pmRelievers,
|
pmRelievers,
|
||||||
|
isHoliday: isHoliday,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Sunday: PM person gets both normal and weekend on_call
|
// Sunday: PM person gets both normal and weekend on_call
|
||||||
if (pmUserId != null) {
|
if (pmUserId != null) {
|
||||||
_tryAddDraft(
|
tryAddShift(
|
||||||
draft,
|
|
||||||
existing,
|
|
||||||
templates,
|
|
||||||
'normal',
|
'normal',
|
||||||
pmUserId,
|
pmUserId,
|
||||||
day,
|
day,
|
||||||
const [],
|
const [],
|
||||||
|
isHoliday: isHoliday,
|
||||||
);
|
);
|
||||||
_tryAddDraft(
|
tryAddShift(
|
||||||
draft,
|
|
||||||
existing,
|
|
||||||
templates,
|
|
||||||
'on_call_sunday',
|
'on_call_sunday',
|
||||||
pmUserId,
|
pmUserId,
|
||||||
day,
|
day,
|
||||||
pmRelievers,
|
pmRelievers,
|
||||||
|
isHoliday: isHoliday,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1791,34 +2006,22 @@ class _ScheduleGeneratorPanelState
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveAmUserId != null) {
|
if (effectiveAmUserId != null) {
|
||||||
_tryAddDraft(
|
tryAddShift(
|
||||||
draft,
|
|
||||||
existing,
|
|
||||||
templates,
|
|
||||||
'am',
|
'am',
|
||||||
effectiveAmUserId,
|
effectiveAmUserId,
|
||||||
day,
|
day,
|
||||||
const [],
|
const [],
|
||||||
|
isHoliday: isHoliday,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (pmUserId != null) {
|
if (pmUserId != null) {
|
||||||
_tryAddDraft(
|
tryAddShift('pm', pmUserId, day, pmRelievers, isHoliday: isHoliday);
|
||||||
draft,
|
tryAddShift(
|
||||||
existing,
|
|
||||||
templates,
|
|
||||||
'pm',
|
|
||||||
pmUserId,
|
|
||||||
day,
|
|
||||||
pmRelievers,
|
|
||||||
);
|
|
||||||
_tryAddDraft(
|
|
||||||
draft,
|
|
||||||
existing,
|
|
||||||
templates,
|
|
||||||
'on_call',
|
'on_call',
|
||||||
pmUserId,
|
pmUserId,
|
||||||
day,
|
day,
|
||||||
pmRelievers,
|
pmRelievers,
|
||||||
|
isHoliday: isHoliday,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1834,34 +2037,69 @@ class _ScheduleGeneratorPanelState
|
||||||
: dayIsRamadan
|
: dayIsRamadan
|
||||||
? 'normal_ramadan_other'
|
? 'normal_ramadan_other'
|
||||||
: 'normal';
|
: 'normal';
|
||||||
_tryAddDraft(
|
tryAddShift(
|
||||||
draft,
|
|
||||||
existing,
|
|
||||||
templates,
|
|
||||||
normalKey,
|
normalKey,
|
||||||
profile.id,
|
profile.id,
|
||||||
day,
|
day,
|
||||||
const [],
|
const [],
|
||||||
displayShiftType: 'normal',
|
displayShiftType: 'normal',
|
||||||
|
isHoliday: isHoliday,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin/Dispatcher always get normal shift (no rotation)
|
// 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) {
|
for (final profile in nonRotating) {
|
||||||
final normalKey = dayIsRamadan && profile.religion == 'islam'
|
final shiftKey = shiftTypeForRole(profile);
|
||||||
? 'normal_ramadan_islam'
|
tryAddShift(
|
||||||
: dayIsRamadan
|
shiftKey,
|
||||||
? 'normal_ramadan_other'
|
|
||||||
: 'normal';
|
|
||||||
_tryAddDraft(
|
|
||||||
draft,
|
|
||||||
existing,
|
|
||||||
templates,
|
|
||||||
normalKey,
|
|
||||||
profile.id,
|
profile.id,
|
||||||
day,
|
day,
|
||||||
const [],
|
const [],
|
||||||
displayShiftType: 'normal',
|
displayShiftType: shiftKey,
|
||||||
|
isHoliday: isHoliday,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1882,6 +2120,7 @@ class _ScheduleGeneratorPanelState
|
||||||
DateTime day,
|
DateTime day,
|
||||||
List<String> relieverIds, {
|
List<String> relieverIds, {
|
||||||
String? displayShiftType,
|
String? displayShiftType,
|
||||||
|
bool isHoliday = false,
|
||||||
}) {
|
}) {
|
||||||
final template = templates[_normalizeShiftType(shiftType)]!;
|
final template = templates[_normalizeShiftType(shiftType)]!;
|
||||||
final start = template.buildStart(day);
|
final start = template.buildStart(day);
|
||||||
|
|
@ -1892,6 +2131,7 @@ class _ScheduleGeneratorPanelState
|
||||||
shiftType: displayShiftType ?? shiftType,
|
shiftType: displayShiftType ?? shiftType,
|
||||||
startTime: start,
|
startTime: start,
|
||||||
endTime: end,
|
endTime: end,
|
||||||
|
isHoliday: isHoliday,
|
||||||
relieverIds: relieverIds,
|
relieverIds: relieverIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1997,6 +2237,7 @@ class _ScheduleGeneratorPanelState
|
||||||
DateTime start,
|
DateTime start,
|
||||||
DateTime end,
|
DateTime end,
|
||||||
List<_DraftSchedule> drafts,
|
List<_DraftSchedule> drafts,
|
||||||
|
RotationConfig? rotationConfig,
|
||||||
) {
|
) {
|
||||||
final warnings = <String>[];
|
final warnings = <String>[];
|
||||||
var day = DateTime(start.year, start.month, start.day);
|
var day = DateTime(start.year, start.month, start.day);
|
||||||
|
|
@ -2025,7 +2266,7 @@ class _ScheduleGeneratorPanelState
|
||||||
for (final shift in required) {
|
for (final shift in required) {
|
||||||
if (!available.contains(shift)) {
|
if (!available.contains(shift)) {
|
||||||
warnings.add(
|
warnings.add(
|
||||||
'${AppTime.formatDate(day)} missing ${_shiftLabel(shift)}',
|
'${AppTime.formatDate(day)} missing ${_shiftLabel(shift, rotationConfig)}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2044,7 +2285,21 @@ class _ScheduleGeneratorPanelState
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _shiftLabel(String 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) {
|
switch (value) {
|
||||||
case 'am':
|
case 'am':
|
||||||
return 'AM Duty';
|
return 'AM Duty';
|
||||||
|
|
@ -2072,6 +2327,7 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
||||||
final swapsAsync = ref.watch(swapRequestsProvider);
|
final swapsAsync = ref.watch(swapRequestsProvider);
|
||||||
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
||||||
final profilesAsync = ref.watch(profilesProvider);
|
final profilesAsync = ref.watch(profilesProvider);
|
||||||
|
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
|
||||||
final currentUserId = ref.watch(currentUserIdProvider);
|
final currentUserId = ref.watch(currentUserIdProvider);
|
||||||
|
|
||||||
final Map<String, DutySchedule> scheduleById = {
|
final Map<String, DutySchedule> scheduleById = {
|
||||||
|
|
@ -2132,13 +2388,13 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
||||||
String subtitle;
|
String subtitle;
|
||||||
if (requesterSchedule != null && targetSchedule != null) {
|
if (requesterSchedule != null && targetSchedule != null) {
|
||||||
subtitle =
|
subtitle =
|
||||||
'${_shiftLabel(requesterSchedule.shiftType)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)} → ${_shiftLabel(targetSchedule.shiftType)} · ${AppTime.formatDate(targetSchedule.startTime)} · ${AppTime.formatTime(targetSchedule.startTime)}';
|
'${_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) {
|
} else if (requesterSchedule != null) {
|
||||||
subtitle =
|
subtitle =
|
||||||
'${_shiftLabel(requesterSchedule.shiftType)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)}';
|
'${_shiftLabel(requesterSchedule.shiftType, rotationConfig)} · ${AppTime.formatDate(requesterSchedule.startTime)} · ${AppTime.formatTime(requesterSchedule.startTime)}';
|
||||||
} else if (item.shiftStartTime != null) {
|
} else if (item.shiftStartTime != null) {
|
||||||
subtitle =
|
subtitle =
|
||||||
'${_shiftLabel(item.shiftType ?? 'normal')} · ${AppTime.formatDate(item.shiftStartTime!)} · ${AppTime.formatTime(item.shiftStartTime!)}';
|
'${_shiftLabel(item.shiftType ?? 'normal', rotationConfig)} · ${AppTime.formatDate(item.shiftStartTime!)} · ${AppTime.formatTime(item.shiftStartTime!)}';
|
||||||
} else {
|
} else {
|
||||||
subtitle = 'Shift not found';
|
subtitle = 'Shift not found';
|
||||||
}
|
}
|
||||||
|
|
@ -2316,7 +2572,21 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
||||||
ref.invalidate(swapRequestsProvider);
|
ref.invalidate(swapRequestsProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _shiftLabel(String 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) {
|
switch (value) {
|
||||||
case 'am':
|
case 'am':
|
||||||
return 'AM Duty';
|
return 'AM Duty';
|
||||||
|
|
|
||||||
51
lib/services/holidays_service.dart
Normal file
51
lib/services/holidays_service.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../models/rotation_config.dart';
|
||||||
|
|
||||||
|
/// Provides holiday lookup via Nager.Date.
|
||||||
|
class HolidaysService {
|
||||||
|
/// Fetches Philippine public holidays for [year].
|
||||||
|
///
|
||||||
|
/// Returns a list of [Holiday] instances.
|
||||||
|
static Future<List<Holiday>> fetchPhilippinesHolidays(int year) async {
|
||||||
|
final uri = Uri.https('date.nager.at', '/api/v3/PublicHolidays/$year/PH');
|
||||||
|
final response = await http.get(uri);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to fetch holidays: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! List) {
|
||||||
|
throw Exception('Unexpected holiday response format');
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((item) {
|
||||||
|
final dateString = item['date'] as String?;
|
||||||
|
final name =
|
||||||
|
item['localName'] as String? ??
|
||||||
|
item['name'] as String? ??
|
||||||
|
'Holiday';
|
||||||
|
|
||||||
|
if (dateString == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsed = DateTime.tryParse(dateString);
|
||||||
|
if (parsed == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Holiday(
|
||||||
|
date: DateTime(parsed.year, parsed.month, parsed.day),
|
||||||
|
name: name,
|
||||||
|
source: 'nager',
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.whereType<Holiday>()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
-- Ensure rotation_config has new defaults without overwriting existing custom values.
|
||||||
|
-- This migration is idempotent: rerunning it does not change already-set values.
|
||||||
|
|
||||||
|
-- Ensure the row exists (older installs may not have created it yet).
|
||||||
|
INSERT INTO app_settings (key, value)
|
||||||
|
VALUES ('rotation_config', '{}'::jsonb)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Add missing keys (only) using a conditional merge.
|
||||||
|
UPDATE app_settings
|
||||||
|
SET value = value || (
|
||||||
|
(CASE
|
||||||
|
WHEN value ? 'shift_types' THEN '{}'::jsonb
|
||||||
|
ELSE jsonb_build_object(
|
||||||
|
'shift_types',
|
||||||
|
'[{"id":"am","label":"AM Duty","start_hour":7,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]},
|
||||||
|
{"id":"pm","label":"PM Duty","start_hour":15,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]},
|
||||||
|
{"id":"on_call","label":"On Call","start_hour":23,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]},
|
||||||
|
{"id":"on_call_saturday","label":"On Call (Saturday)","start_hour":17,"start_minute":0,"duration_minutes":900,"allowed_roles":["it_staff"]},
|
||||||
|
{"id":"on_call_sunday","label":"On Call (Sunday)","start_hour":17,"start_minute":0,"duration_minutes":840,"allowed_roles":["it_staff"]},
|
||||||
|
{"id":"normal","label":"Normal","start_hour":8,"start_minute":0,"duration_minutes":540,"allowed_roles":["admin","dispatcher","programmer","it_staff"]},
|
||||||
|
{"id":"normal_ramadan_islam","label":"Normal (Ramadan - Islam)","start_hour":8,"start_minute":0,"duration_minutes":480,"allowed_roles":["admin","dispatcher","programmer","it_staff"]},
|
||||||
|
{"id":"normal_ramadan_other","label":"Normal (Ramadan - Other)","start_hour":8,"start_minute":0,"duration_minutes":540,"allowed_roles":["admin","dispatcher","programmer","it_staff"]}]'::jsonb
|
||||||
|
)
|
||||||
|
END)
|
||||||
|
|| (CASE WHEN value ? 'role_weekly_hours' THEN '{}'::jsonb ELSE jsonb_build_object('role_weekly_hours', '{}'::jsonb) END)
|
||||||
|
|| (CASE WHEN value ? 'holidays' THEN '{}'::jsonb ELSE jsonb_build_object('holidays', '[]'::jsonb) END)
|
||||||
|
|| (CASE WHEN value ? 'sync_philippines_holidays' THEN '{}'::jsonb ELSE jsonb_build_object('sync_philippines_holidays', false) END)
|
||||||
|
|| (CASE WHEN value ? 'holidays_year' THEN '{}'::jsonb ELSE jsonb_build_object('holidays_year', NULL) END)
|
||||||
|
)
|
||||||
|
WHERE key = 'rotation_config';
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
-- Merge stored shift_types with defaults so new default shift types automatically appear
|
||||||
|
-- without overwriting existing customizations.
|
||||||
|
|
||||||
|
UPDATE app_settings
|
||||||
|
SET value = jsonb_set(
|
||||||
|
value,
|
||||||
|
'{shift_types}',
|
||||||
|
(
|
||||||
|
WITH
|
||||||
|
-- Default shift types (must match RotationConfig._defaultShiftTypes())
|
||||||
|
defaults AS (
|
||||||
|
SELECT '[
|
||||||
|
{"id":"am","label":"AM Duty","start_hour":7,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]},
|
||||||
|
{"id":"pm","label":"PM Duty","start_hour":15,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]},
|
||||||
|
{"id":"on_call","label":"On Call","start_hour":23,"start_minute":0,"duration_minutes":480,"allowed_roles":["it_staff"]},
|
||||||
|
{"id":"on_call_saturday","label":"On Call (Saturday)","start_hour":17,"start_minute":0,"duration_minutes":900,"allowed_roles":["it_staff"]},
|
||||||
|
{"id":"on_call_sunday","label":"On Call (Sunday)","start_hour":17,"start_minute":0,"duration_minutes":840,"allowed_roles":["it_staff"]},
|
||||||
|
{"id":"normal","label":"Normal","start_hour":8,"start_minute":0,"duration_minutes":540,"allowed_roles":["admin","dispatcher","programmer","it_staff"]},
|
||||||
|
{"id":"normal_ramadan_islam","label":"Normal (Ramadan - Islam)","start_hour":8,"start_minute":0,"duration_minutes":480,"allowed_roles":["admin","dispatcher","programmer","it_staff"]},
|
||||||
|
{"id":"normal_ramadan_other","label":"Normal (Ramadan - Other)","start_hour":8,"start_minute":0,"duration_minutes":540,"allowed_roles":["admin","dispatcher","programmer","it_staff"]}
|
||||||
|
]'::jsonb AS val
|
||||||
|
),
|
||||||
|
stored AS (
|
||||||
|
SELECT value->'shift_types' AS val
|
||||||
|
FROM app_settings
|
||||||
|
WHERE key = 'rotation_config'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
-- Start with defaults, overriding with stored by id
|
||||||
|
(
|
||||||
|
SELECT jsonb_agg(COALESCE(stored_elem_val, default_elem))
|
||||||
|
FROM defaults, stored,
|
||||||
|
LATERAL jsonb_array_elements(defaults.val) default_elem
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT s AS stored_elem_val
|
||||||
|
FROM jsonb_array_elements(stored.val) s
|
||||||
|
WHERE s->>'id' = default_elem->>'id'
|
||||||
|
LIMIT 1
|
||||||
|
) stored_elem ON true
|
||||||
|
)
|
||||||
|
-- Append any stored entries that aren't in defaults
|
||||||
|
|| COALESCE(
|
||||||
|
(
|
||||||
|
SELECT jsonb_agg(s)
|
||||||
|
FROM stored,
|
||||||
|
jsonb_array_elements(stored.val) s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM defaults,
|
||||||
|
LATERAL jsonb_array_elements(defaults.val) d
|
||||||
|
WHERE d->>'id' = s->>'id'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE key = 'rotation_config'
|
||||||
|
AND (value ? 'shift_types');
|
||||||
Loading…
Reference in New Issue
Block a user