Pass Slip and Leave approval/rejection push notifications
This commit is contained in:
parent
6fd3b66251
commit
885b543fb5
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic>? inserted = insertedRaw is Map<String, dynamic>
|
||||
? 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 ?? <String, dynamic>{})['id']?.toString() ?? '';
|
||||
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': 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<void> 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<void> 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<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.
|
||||
|
|
@ -108,4 +270,25 @@ class LeaveController {
|
|||
.update({'status': 'cancelled'})
|
||||
.eq('id', leaveId);
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic>? inserted = insertedRaw is Map<String, dynamic>
|
||||
? 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 ?? <String, dynamic>{})['id']?.toString() ?? '';
|
||||
final title = 'Pass Slip Filed for Approval';
|
||||
final body = '$actorName filed a pass slip that requires approval.';
|
||||
final notificationId = (inserted ?? <String, dynamic>{})['id']
|
||||
?.toString();
|
||||
|
||||
final dataPayload = <String, dynamic>{
|
||||
'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<void> 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<void> 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<void> _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<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 ? 'Pass Slip Approved' : 'Pass Slip Rejected';
|
||||
final body = approved
|
||||
? '$actorName approved your pass slip.'
|
||||
: '$actorName rejected your pass slip.';
|
||||
|
||||
final dataPayload = <String, dynamic>{
|
||||
'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<void> completeSlip(String slipId) async {
|
||||
|
|
@ -120,4 +276,25 @@ class PassSlipController {
|
|||
})
|
||||
.eq('id', slipId);
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -836,11 +836,55 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
|||
),
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user