Files
tasq/lib/providers/leave_provider.dart
T
2026-04-27 06:20:39 +08:00

499 lines
16 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../models/leave_of_absence.dart';
import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
// ---------------------------------------------------------------------------
// Offline-pending state
// ---------------------------------------------------------------------------
/// Leaves filed offline that haven't been confirmed by Supabase yet. Merged
/// into [leavesProvider] so the UI shows them immediately. Cleared as the
/// realtime stream confirms each id.
final offlinePendingLeavesProvider =
StateProvider<List<LeaveOfAbsence>>((ref) => const []);
/// Status changes (approve/reject/cancel) made offline, keyed by leave id.
/// On reconnect the listener PATCHes each entry and clears the map.
final offlinePendingLeaveUpdatesProvider =
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
// ---------------------------------------------------------------------------
// Read provider
// ---------------------------------------------------------------------------
/// All visible leaves (own for standard, all for admin/dispatcher/it_staff).
///
/// Consumers should **not** treat every record as an active absence; the UI
/// layers (dashboard, logbook) explicitly filter to `status == 'approved'` and
/// verify the leave overlaps the current time.
final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) return Stream.value(const <LeaveOfAbsence>[]);
final hasFullAccess =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
// Watch pending state — provider rebuilds when offline writes arrive so the
// closed-over `pending*` values feed into `onOfflineData` synchronously.
final pendingNew = ref.watch(offlinePendingLeavesProvider);
final pendingUpdates = ref.watch(offlinePendingLeaveUpdatesProvider);
List<LeaveOfAbsence> applyPending(List<LeaveOfAbsence> rows) {
final byId = {for (final r in rows) r.id: r};
// Merge offline status updates onto existing leaves
pendingUpdates.forEach((id, fields) {
final existing = byId[id];
if (existing == null) return;
byId[id] = LeaveOfAbsence(
id: existing.id,
userId: existing.userId,
leaveType: existing.leaveType,
justification: existing.justification,
startTime: existing.startTime,
endTime: existing.endTime,
status: (fields['status'] as String?) ?? existing.status,
filedBy: existing.filedBy,
createdAt: existing.createdAt,
);
});
// Append offline-created leaves not yet on the server
for (final p in pendingNew) {
if (hasFullAccess || p.userId == profile.id) {
byId.putIfAbsent(p.id, () => p);
}
}
final merged = byId.values.toList()
..sort((a, b) => b.startTime.compareTo(a.startTime));
return merged;
}
final wrapper = StreamRecoveryWrapper<LeaveOfAbsence>(
stream: hasFullAccess
? client
.from('leave_of_absence')
.stream(primaryKey: ['id'])
.order('start_time', ascending: false)
: client
.from('leave_of_absence')
.stream(primaryKey: ['id'])
.eq('user_id', profile.id)
.order('start_time', ascending: false),
onPollData: () async {
final query = client.from('leave_of_absence').select();
final data = hasFullAccess
? await query.order('start_time', ascending: false)
: await query
.eq('user_id', profile.id)
.order('start_time', ascending: false);
return data.map(LeaveOfAbsence.fromMap).toList();
},
fromMap: LeaveOfAbsence.fromMap,
channelName: 'leave_of_absence',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<LeaveOfAbsence>();
final filtered = hasFullAccess
? all
: all.where((l) => l.userId == profile.id).toList();
return applyPending(filtered);
},
onCacheMirror: (rows) =>
mirrorBatchToBrick<LeaveOfAbsence>(rows, tag: 'leave_of_absence'),
);
ref.onDispose(wrapper.dispose);
// Reconnect-replay: directly POST queued items, bypassing Brick's queue
// which can block on stale entries (504, deadlock, etc.).
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
if (wasOnline == true || !isNowOnline) return;
// 1. Replay offline-created leaves
final pending = List<LeaveOfAbsence>.from(ref.read(offlinePendingLeavesProvider));
if (pending.isNotEmpty) {
debugPrint('[leavesProvider] reconnected — syncing ${pending.length} leave(s)');
final synced = <String>[];
for (final leave in pending) {
try {
await client.from('leave_of_absence').insert({
'id': leave.id,
'user_id': leave.userId,
'leave_type': leave.leaveType,
'justification': leave.justification,
'start_time': leave.startTime.toIso8601String(),
'end_time': leave.endTime.toIso8601String(),
'status': leave.status,
'filed_by': leave.filedBy,
});
synced.add(leave.id);
} catch (e) {
final msg = e.toString();
if (msg.contains('23505') || msg.contains('duplicate key')) {
synced.add(leave.id); // already in DB — drop pending entry
} else {
debugPrint('[leavesProvider] failed to sync leave id=${leave.id}: $e');
}
}
}
if (synced.isNotEmpty) {
final remaining = ref.read(offlinePendingLeavesProvider)
.where((l) => !synced.contains(l.id))
.toList();
ref.read(offlinePendingLeavesProvider.notifier).state =
List.unmodifiable(remaining);
}
}
// 2. Replay offline status updates
final pendingMap = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingLeaveUpdatesProvider),
);
if (pendingMap.isNotEmpty) {
debugPrint(
'[leavesProvider] reconnected — syncing ${pendingMap.length} status edit(s)',
);
final syncedIds = <String>[];
for (final entry in pendingMap.entries) {
try {
await client
.from('leave_of_absence')
.update(entry.value)
.eq('id', entry.key);
syncedIds.add(entry.key);
} catch (e) {
debugPrint('[leavesProvider] failed to sync edit id=${entry.key}: $e');
}
}
if (syncedIds.isNotEmpty) {
final remaining = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingLeaveUpdatesProvider),
);
for (final id in syncedIds) {
remaining.remove(id);
}
ref.read(offlinePendingLeaveUpdatesProvider.notifier).state = remaining;
}
}
});
return wrapper.stream.map((result) => applyPending(result.data));
});
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
final leaveControllerProvider = Provider<LeaveController>((ref) {
final client = ref.watch(supabaseClientProvider);
return LeaveController(client, ref);
});
class LeaveController {
LeaveController(this._client, [this._ref]);
final SupabaseClient _client;
final Ref? _ref;
/// File a leave of absence for the current user.
/// Caller controls auto-approval based on role policy.
/// Offline: queues a local row + raw payload, returns silently.
Future<void> fileLeave({
required String leaveType,
required String justification,
required DateTime startTime,
required DateTime endTime,
required bool autoApprove,
}) async {
final uid = _client.auth.currentUser!.id;
final id = const Uuid().v4();
final status = autoApprove ? 'approved' : 'pending';
final payload = {
'id': id,
'user_id': uid,
'leave_type': leaveType,
'justification': justification,
'start_time': startTime.toIso8601String(),
'end_time': endTime.toIso8601String(),
'status': status,
'filed_by': uid,
};
Map<String, dynamic>? inserted;
try {
final insertedRaw = await _client
.from('leave_of_absence')
.insert(payload)
.select()
.maybeSingle();
inserted = insertedRaw is Map<String, dynamic> ? insertedRaw : null;
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queuePendingLeave(
LeaveOfAbsence(
id: id,
userId: uid,
leaveType: leaveType,
justification: justification,
startTime: startTime,
endTime: endTime,
status: status,
filedBy: uid,
createdAt: AppTime.now(),
),
);
return; // Don't try to notify admins — we're offline.
}
// If this was filed as pending, notify admins for approval
if (status != 'pending') return;
await _notifyAdminsOfFiling(inserted: inserted, leaveId: id, actorId: uid);
}
void _queuePendingLeave(LeaveOfAbsence leave) {
final ref = _ref;
if (ref == null) return;
final current = List<LeaveOfAbsence>.from(
ref.read(offlinePendingLeavesProvider),
);
current.add(leave);
ref.read(offlinePendingLeavesProvider.notifier).state =
List.unmodifiable(current);
mirrorBatchToBrick<LeaveOfAbsence>([leave], tag: 'offline_leave');
}
void _queueLeaveStatusUpdate(String leaveId, Map<String, dynamic> fields) {
final ref = _ref;
if (ref == null) return;
final current = Map<String, Map<String, dynamic>>.from(
ref.read(offlinePendingLeaveUpdatesProvider),
);
current[leaveId] = {...?current[leaveId], ...fields};
ref.read(offlinePendingLeaveUpdatesProvider.notifier).state = current;
}
Future<void> _notifyAdminsOfFiling({
required Map<String, dynamic>? inserted,
required String leaveId,
required String actorId,
}) async {
try {
final adminIds = await _fetchRoleUserIds(
roles: const ['admin'],
excludeUserId: actorId,
);
if (adminIds.isEmpty) return;
String actorName = 'Someone';
try {
final p = await _client
.from('profiles')
.select('full_name,display_name,name')
.eq('id', actorId)
.maybeSingle();
if (p != null) {
if (p['full_name'] != null) {
actorName = p['full_name'].toString();
} else if (p['display_name'] != null) {
actorName = p['display_name'].toString();
} else if (p['name'] != null) {
actorName = p['name'].toString();
}
}
} catch (_) {}
final title = 'Leave Filed for Approval';
final body = '$actorName filed a leave request that requires approval.';
final notificationId = (inserted ?? <String, dynamic>{})['id']?.toString();
final dataPayload = <String, dynamic>{
'type': 'leave_filed',
'leave_id': leaveId,
...?(notificationId != null ? {'notification_id': notificationId} : null),
};
await _client.from('notifications').insert(
adminIds
.map((userId) => {
'user_id': userId,
'actor_id': actorId,
'type': 'leave_filed',
'leave_id': leaveId,
})
.toList(),
);
final res = await _client.functions.invoke(
'send_fcm',
body: {
'user_ids': adminIds,
'title': title,
'body': body,
'data': dataPayload,
},
);
debugPrint('leave filing send_fcm result: $res');
} catch (e) {
debugPrint('leave filing send_fcm error: $e');
// Non-fatal: keep leave filing working even if send_fcm fails
}
}
/// Approve a leave request.
Future<void> approveLeave(String leaveId) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
try {
await _client
.from('leave_of_absence')
.update({'status': 'approved'})
.eq('id', leaveId);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queueLeaveStatusUpdate(leaveId, {'status': 'approved'});
return;
}
// Notify requestor (best-effort)
try {
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: true);
} catch (_) {}
}
/// Reject a leave request.
Future<void> rejectLeave(String leaveId) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
try {
await _client
.from('leave_of_absence')
.update({'status': 'rejected'})
.eq('id', leaveId);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queueLeaveStatusUpdate(leaveId, {'status': 'rejected'});
return;
}
try {
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: false);
} catch (_) {}
}
Future<void> _notifyRequester({
required String leaveId,
required String actorId,
required bool approved,
}) async {
try {
final row = await _client
.from('leave_of_absence')
.select('user_id')
.eq('id', leaveId)
.maybeSingle();
// ignore: unnecessary_cast
final rowMap = row as Map<String, dynamic>?;
final userId = rowMap?['user_id']?.toString();
if (userId == null || userId.isEmpty) return;
String actorName = 'Someone';
try {
final p = await _client
.from('profiles')
.select('full_name,display_name,name')
.eq('id', actorId)
.maybeSingle();
if (p != null) {
if (p['full_name'] != null) {
actorName = p['full_name'].toString();
} else if (p['display_name'] != null) {
actorName = p['display_name'].toString();
} else if (p['name'] != null) {
actorName = p['name'].toString();
}
}
} catch (_) {}
final title = approved ? 'Leave Approved' : 'Leave Rejected';
final body = approved
? '$actorName approved your leave request.'
: '$actorName rejected your leave request.';
final dataPayload = <String, dynamic>{
'type': approved ? 'leave_approved' : 'leave_rejected',
'leave_id': leaveId,
};
await _client.from('notifications').insert({
'user_id': userId,
'actor_id': actorId,
'type': approved ? 'leave_approved' : 'leave_rejected',
'leave_id': leaveId,
});
await _client.functions.invoke(
'send_fcm',
body: {
'user_ids': [userId],
'title': title,
'body': body,
'data': dataPayload,
},
);
} catch (_) {
// non-fatal
}
}
/// Cancel an approved leave.
Future<void> cancelLeave(String leaveId) async {
try {
await _client
.from('leave_of_absence')
.update({'status': 'cancelled'})
.eq('id', leaveId);
} catch (e) {
if (!isOfflineSaveError(e)) rethrow;
_queueLeaveStatusUpdate(leaveId, {'status': 'cancelled'});
}
}
Future<List<String>> _fetchRoleUserIds({
required List<String> roles,
required String? excludeUserId,
}) async {
try {
final data = await _client
.from('profiles')
.select('id, role')
.inFilter('role', roles);
final rows = data as List<dynamic>;
final ids = rows
.map((row) => row['id'] as String?)
.whereType<String>()
.where((id) => id.isNotEmpty && id != excludeUserId)
.toList();
return ids;
} catch (_) {
return [];
}
}
}