321 lines
9.7 KiB
Dart
321 lines
9.7 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
|
|
import '../models/pass_slip.dart';
|
|
import 'profile_provider.dart';
|
|
import 'supabase_provider.dart';
|
|
import 'stream_recovery.dart';
|
|
import 'realtime_controller.dart';
|
|
|
|
/// All visible pass slips (own for staff, all for admin/dispatcher).
|
|
final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
final profileAsync = ref.watch(currentProfileProvider);
|
|
final profile = profileAsync.valueOrNull;
|
|
if (profile == null) return Stream.value(const <PassSlip>[]);
|
|
|
|
final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher';
|
|
final hasFullAccess = isAdmin || profile.role == 'programmer';
|
|
|
|
final wrapper = StreamRecoveryWrapper<PassSlip>(
|
|
stream: hasFullAccess
|
|
? client
|
|
.from('pass_slips')
|
|
.stream(primaryKey: ['id'])
|
|
.order('requested_at', ascending: false)
|
|
: client
|
|
.from('pass_slips')
|
|
.stream(primaryKey: ['id'])
|
|
.eq('user_id', profile.id)
|
|
.order('requested_at', ascending: false),
|
|
onPollData: () async {
|
|
final query = client.from('pass_slips').select();
|
|
final data = hasFullAccess
|
|
? await query.order('requested_at', ascending: false)
|
|
: await query
|
|
.eq('user_id', profile.id)
|
|
.order('requested_at', ascending: false);
|
|
return data.map(PassSlip.fromMap).toList();
|
|
},
|
|
fromMap: PassSlip.fromMap,
|
|
channelName: 'pass_slips',
|
|
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
return wrapper.stream.map((result) => result.data);
|
|
});
|
|
|
|
/// Currently active pass slip for the logged-in user (approved, not completed).
|
|
final activePassSlipProvider = Provider<PassSlip?>((ref) {
|
|
final slips = ref.watch(passSlipsProvider).valueOrNull ?? [];
|
|
final userId = ref.watch(currentUserIdProvider);
|
|
if (userId == null) return null;
|
|
try {
|
|
return slips.firstWhere((s) => s.userId == userId && s.isActive);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
/// Active pass slips for all users (for dashboard IT Staff Pulse).
|
|
final activePassSlipsProvider = Provider<List<PassSlip>>((ref) {
|
|
final slips = ref.watch(passSlipsProvider).valueOrNull ?? [];
|
|
return slips.where((s) => s.isActive).toList();
|
|
});
|
|
|
|
final passSlipControllerProvider = Provider<PassSlipController>((ref) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
return PassSlipController(client);
|
|
});
|
|
|
|
class PassSlipController {
|
|
PassSlipController(this._client);
|
|
|
|
final SupabaseClient _client;
|
|
|
|
Future<void> requestSlip({
|
|
required String dutyScheduleId,
|
|
required String reason,
|
|
DateTime? requestedStart,
|
|
}) async {
|
|
final userId = _client.auth.currentUser?.id;
|
|
if (userId == null) throw Exception('Not authenticated');
|
|
final payload = {
|
|
'user_id': userId,
|
|
'duty_schedule_id': dutyScheduleId,
|
|
'reason': reason,
|
|
'status': 'pending',
|
|
'requested_at': DateTime.now().toUtc().toIso8601String(),
|
|
if (requestedStart != null)
|
|
'requested_start': requestedStart.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');
|
|
|
|
// Determine slip start time based on requested_start
|
|
final nowUtc = DateTime.now().toUtc();
|
|
String slipStartIso = nowUtc.toIso8601String();
|
|
|
|
final row = await _client
|
|
.from('pass_slips')
|
|
.select('requested_start')
|
|
.eq('id', slipId)
|
|
.maybeSingle();
|
|
if (row != null && row['requested_start'] != null) {
|
|
final requestedStart = DateTime.parse(row['requested_start'] as String);
|
|
if (requestedStart.isAfter(nowUtc)) {
|
|
slipStartIso = requestedStart.toIso8601String();
|
|
}
|
|
}
|
|
|
|
await _client
|
|
.from('pass_slips')
|
|
.update({
|
|
'status': 'approved',
|
|
'approved_by': userId,
|
|
'approved_at': nowUtc.toIso8601String(),
|
|
'slip_start': slipStartIso,
|
|
})
|
|
.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': 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 {
|
|
await _client
|
|
.from('pass_slips')
|
|
.update({
|
|
'status': 'completed',
|
|
'slip_end': DateTime.now().toUtc().toIso8601String(),
|
|
})
|
|
.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 [];
|
|
}
|
|
}
|
|
}
|