diff --git a/lib/models/notification_item.dart b/lib/models/notification_item.dart index 3fafba89..7e266ad1 100644 --- a/lib/models/notification_item.dart +++ b/lib/models/notification_item.dart @@ -7,6 +7,8 @@ class NotificationItem { required this.actorId, required this.ticketId, required this.taskId, + this.leaveId, + this.passSlipId, required this.itServiceRequestId, required this.messageId, required this.type, @@ -19,6 +21,8 @@ class NotificationItem { final String? actorId; final String? ticketId; final String? taskId; + final String? leaveId; + final String? passSlipId; final String? itServiceRequestId; final int? messageId; final String type; @@ -34,6 +38,8 @@ class NotificationItem { actorId: map['actor_id'] as String?, ticketId: map['ticket_id'] as String?, taskId: map['task_id'] as String?, + leaveId: map['leave_id'] as String?, + passSlipId: map['pass_slip_id'] as String?, itServiceRequestId: map['it_service_request_id'] as String?, messageId: map['message_id'] as int?, type: map['type'] as String? ?? 'mention', diff --git a/lib/providers/leave_provider.dart b/lib/providers/leave_provider.dart index 71332854..387c0a42 100644 --- a/lib/providers/leave_provider.dart +++ b/lib/providers/leave_provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -74,7 +75,7 @@ class LeaveController { required bool autoApprove, }) async { final uid = _client.auth.currentUser!.id; - await _client.from('leave_of_absence').insert({ + final payload = { 'user_id': uid, 'leave_type': leaveType, 'justification': justification, @@ -82,23 +83,184 @@ class LeaveController { 'end_time': endTime.toIso8601String(), 'status': autoApprove ? 'approved' : 'pending', 'filed_by': uid, - }); + }; + + final insertedRaw = await _client + .from('leave_of_absence') + .insert(payload) + .select() + .maybeSingle(); + final Map? inserted = insertedRaw is Map + ? insertedRaw + : null; + + // If this was filed as pending, notify admins for approval + final status = payload['status'] as String; + if (status != 'pending') return; + + try { + final adminIds = await _fetchRoleUserIds( + roles: const ['admin'], + excludeUserId: uid, + ); + if (adminIds.isEmpty) return; + + // Resolve actor display name for nicer push text + String actorName = 'Someone'; + try { + final p = await _client + .from('profiles') + .select('full_name,display_name,name') + .eq('id', uid) + .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 leaveId = (inserted ?? {})['id']?.toString() ?? ''; + final title = 'Leave Filed for Approval'; + final body = '$actorName filed a leave request that requires approval.'; + final notificationId = (inserted ?? {})['id'] + ?.toString(); + + final dataPayload = { + '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': uid, + '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 approveLeave(String leaveId) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + + // Update status first; then notify the requester. await _client .from('leave_of_absence') .update({'status': 'approved'}) .eq('id', leaveId); + + // Notify requestor + await _notifyRequester(leaveId: leaveId, actorId: userId, approved: true); } /// Reject a leave request. Future rejectLeave(String leaveId) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + await _client .from('leave_of_absence') .update({'status': 'rejected'}) .eq('id', leaveId); + + // Notify requestor + await _notifyRequester(leaveId: leaveId, actorId: userId, approved: false); + } + + Future _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?; + 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 = { + '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. @@ -108,4 +270,25 @@ class LeaveController { .update({'status': 'cancelled'}) .eq('id', leaveId); } + + Future> _fetchRoleUserIds({ + required List roles, + required String? excludeUserId, + }) async { + try { + final data = await _client + .from('profiles') + .select('id, role') + .inFilter('role', roles); + final rows = data as List; + final ids = rows + .map((row) => row['id'] as String?) + .whereType() + .where((id) => id.isNotEmpty && id != excludeUserId) + .toList(); + return ids; + } catch (_) { + return []; + } + } } diff --git a/lib/providers/notifications_provider.dart b/lib/providers/notifications_provider.dart index aa562833..8d56a688 100644 --- a/lib/providers/notifications_provider.dart +++ b/lib/providers/notifications_provider.dart @@ -264,7 +264,8 @@ class NotificationsController { 'data': data ?? {}, }; - await _client.functions.invoke('send_fcm', body: bodyPayload); + final res = await _client.functions.invoke('send_fcm', body: bodyPayload); + debugPrint('send_fcm result: $res'); } catch (err) { debugPrint('sendPush invocation error: $err'); } diff --git a/lib/providers/pass_slip_provider.dart b/lib/providers/pass_slip_provider.dart index bddbd8c1..2bc1ddd3 100644 --- a/lib/providers/pass_slip_provider.dart +++ b/lib/providers/pass_slip_provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -79,16 +80,99 @@ class PassSlipController { }) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); - await _client.from('pass_slips').insert({ + final payload = { 'user_id': userId, 'duty_schedule_id': dutyScheduleId, 'reason': reason, - }); + 'status': 'pending', + 'requested_at': DateTime.now().toUtc().toIso8601String(), + }; + + final insertedRaw = await _client + .from('pass_slips') + .insert(payload) + .select() + .maybeSingle(); + final Map? inserted = insertedRaw is Map + ? insertedRaw + : null; + + // Notify admins for approval + try { + final adminIds = await _fetchRoleUserIds( + roles: const ['admin'], + excludeUserId: userId, + ); + if (adminIds.isEmpty) return; + + // Resolve actor display name for nice push text + String actorName = 'Someone'; + try { + final p = await _client + .from('profiles') + .select('full_name,display_name,name') + .eq('id', userId) + .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 slipId = (inserted ?? {})['id']?.toString() ?? ''; + final title = 'Pass Slip Filed for Approval'; + final body = '$actorName filed a pass slip that requires approval.'; + final notificationId = (inserted ?? {})['id'] + ?.toString(); + + final dataPayload = { + 'type': 'pass_slip_filed', + 'pass_slip_id': slipId, + ...?(notificationId != null + ? {'notification_id': notificationId} + : null), + }; + + await _client + .from('notifications') + .insert( + adminIds + .map( + (adminId) => { + 'user_id': adminId, + 'actor_id': userId, + 'type': 'pass_slip_filed', + 'pass_slip_id': slipId, + }, + ) + .toList(), + ); + + final res = await _client.functions.invoke( + 'send_fcm', + body: { + 'user_ids': adminIds, + 'title': title, + 'body': body, + 'data': dataPayload, + }, + ); + debugPrint('pass slip send_fcm result: $res'); + } catch (e) { + debugPrint('pass slip send_fcm error: $e'); + // Non-fatal: keep slip request working even if send_fcm fails + } } Future approveSlip(String slipId) async { final userId = _client.auth.currentUser?.id; if (userId == null) throw Exception('Not authenticated'); + await _client .from('pass_slips') .update({ @@ -98,17 +182,89 @@ class PassSlipController { 'slip_start': DateTime.now().toUtc().toIso8601String(), }) .eq('id', slipId); + + await _notifyRequester(slipId: slipId, actorId: userId, approved: true); } Future rejectSlip(String slipId) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + await _client .from('pass_slips') .update({ 'status': 'rejected', - 'approved_by': _client.auth.currentUser?.id, + 'approved_by': userId, 'approved_at': DateTime.now().toUtc().toIso8601String(), }) .eq('id', slipId); + + await _notifyRequester(slipId: slipId, actorId: userId, approved: false); + } + + Future _notifyRequester({ + required String slipId, + required String actorId, + required bool approved, + }) async { + try { + final row = await _client + .from('pass_slips') + .select('user_id') + .eq('id', slipId) + .maybeSingle(); + // ignore: unnecessary_cast + final rowMap = row as Map?; + 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 ? 'Pass Slip Approved' : 'Pass Slip Rejected'; + final body = approved + ? '$actorName approved your pass slip.' + : '$actorName rejected your pass slip.'; + + final dataPayload = { + 'type': approved ? 'pass_slip_approved' : 'pass_slip_rejected', + 'pass_slip_id': slipId, + }; + + await _client.from('notifications').insert({ + 'user_id': userId, + 'actor_id': actorId, + 'type': approved ? 'pass_slip_approved' : 'pass_slip_rejected', + 'pass_slip_id': slipId, + }); + + await _client.functions.invoke( + 'send_fcm', + body: { + 'user_ids': [userId], + 'title': title, + 'body': body, + 'data': dataPayload, + }, + ); + } catch (_) { + // non-fatal + } } Future completeSlip(String slipId) async { @@ -120,4 +276,25 @@ class PassSlipController { }) .eq('id', slipId); } + + Future> _fetchRoleUserIds({ + required List roles, + required String? excludeUserId, + }) async { + try { + final data = await _client + .from('profiles') + .select('id, role') + .inFilter('role', roles); + final rows = data as List; + final ids = rows + .map((row) => row['id'] as String?) + .whereType() + .where((id) => id.isNotEmpty && id != excludeUserId) + .toList(); + return ids; + } catch (_) { + return []; + } + } } diff --git a/lib/screens/admin/app_update_screen.dart b/lib/screens/admin/app_update_screen.dart index 7bf7ad89..595ea5f7 100644 --- a/lib/screens/admin/app_update_screen.dart +++ b/lib/screens/admin/app_update_screen.dart @@ -836,11 +836,55 @@ class _AppUpdateScreenState extends ConsumerState { ), const SizedBox(height: 12), ], - if (_error != null) - Text( - _error!, - style: const TextStyle(color: Colors.red), + if (_error != null) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline, color: Colors.red), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: const TextStyle(color: Colors.red), + ), + ), + ], ), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton( + onPressed: _isUploading + ? null + : () { + setState(() { + _error = null; + }); + _submit(); + }, + child: const Text('Retry'), + ), + const SizedBox(width: 8), + TextButton( + onPressed: _isUploading + ? null + : () { + setState(() { + _error = null; + _apkBytes = null; + _apkName = null; + _progress = null; + _realProgress = 0; + _eta = null; + _logs.clear(); + }); + }, + child: const Text('Reset'), + ), + ], + ), + const SizedBox(height: 12), + ], ElevatedButton( onPressed: _isUploading ? null : _submit, child: _isUploading