164 lines
5.2 KiB
Dart
164 lines
5.2 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
|
|
import '../models/attendance_log.dart';
|
|
import '../utils/app_time.dart';
|
|
import 'profile_provider.dart';
|
|
import 'reports_provider.dart';
|
|
import 'supabase_provider.dart';
|
|
import 'stream_recovery.dart';
|
|
import 'realtime_controller.dart';
|
|
|
|
/// Date range for attendance logbook, defaults to "Last 7 Days".
|
|
final attendanceDateRangeProvider = StateProvider<ReportDateRange>((ref) {
|
|
final now = AppTime.now();
|
|
final today = DateTime(now.year, now.month, now.day);
|
|
return ReportDateRange(
|
|
start: today,
|
|
end: today.add(const Duration(days: 1)),
|
|
label: 'Today',
|
|
);
|
|
});
|
|
|
|
/// Filter for logbook users (multi-select). If empty, all users are shown.
|
|
final attendanceUserFilterProvider = StateProvider<List<String>>((ref) => []);
|
|
|
|
/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
|
|
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
final profileAsync = ref.watch(currentProfileProvider);
|
|
final profile = profileAsync.valueOrNull;
|
|
if (profile == null) return Stream.value(const <AttendanceLog>[]);
|
|
|
|
final hasFullAccess =
|
|
profile.role == 'admin' ||
|
|
profile.role == 'programmer' ||
|
|
profile.role == 'dispatcher' ||
|
|
profile.role == 'it_staff';
|
|
|
|
final wrapper = StreamRecoveryWrapper<AttendanceLog>(
|
|
stream: hasFullAccess
|
|
? client
|
|
.from('attendance_logs')
|
|
.stream(primaryKey: ['id'])
|
|
.order('check_in_at', ascending: false)
|
|
: client
|
|
.from('attendance_logs')
|
|
.stream(primaryKey: ['id'])
|
|
.eq('user_id', profile.id)
|
|
.order('check_in_at', ascending: false),
|
|
onPollData: () async {
|
|
final query = client.from('attendance_logs').select();
|
|
final data = hasFullAccess
|
|
? await query.order('check_in_at', ascending: false)
|
|
: await query
|
|
.eq('user_id', profile.id)
|
|
.order('check_in_at', ascending: false);
|
|
return data.map(AttendanceLog.fromMap).toList();
|
|
},
|
|
fromMap: AttendanceLog.fromMap,
|
|
channelName: 'attendance_logs',
|
|
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
return wrapper.stream.map((result) => result.data);
|
|
});
|
|
|
|
final attendanceControllerProvider = Provider<AttendanceController>((ref) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
return AttendanceController(client);
|
|
});
|
|
|
|
class AttendanceController {
|
|
AttendanceController(this._client);
|
|
|
|
final SupabaseClient _client;
|
|
|
|
/// Check in to a duty schedule. Returns the attendance log ID.
|
|
Future<String?> checkIn({
|
|
required String dutyScheduleId,
|
|
required double lat,
|
|
required double lng,
|
|
}) async {
|
|
final data = await _client.rpc(
|
|
'attendance_check_in',
|
|
params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng},
|
|
);
|
|
return data as String?;
|
|
}
|
|
|
|
/// Check out from an attendance log.
|
|
Future<void> checkOut({
|
|
required String attendanceId,
|
|
required double lat,
|
|
required double lng,
|
|
String? justification,
|
|
}) async {
|
|
await _client.rpc(
|
|
'attendance_check_out',
|
|
params: {
|
|
'p_attendance_id': attendanceId,
|
|
'p_lat': lat,
|
|
'p_lng': lng,
|
|
'p_justification': justification,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Overtime check-in (no pre-existing schedule required).
|
|
/// Creates an overtime duty schedule + attendance log in one RPC call.
|
|
Future<String?> overtimeCheckIn({
|
|
required double lat,
|
|
required double lng,
|
|
String? justification,
|
|
}) async {
|
|
final data = await _client.rpc(
|
|
'overtime_check_in',
|
|
params: {'p_lat': lat, 'p_lng': lng, 'p_justification': justification},
|
|
);
|
|
return data as String?;
|
|
}
|
|
|
|
/// Upload a verification selfie and update the attendance log.
|
|
Future<void> uploadVerification({
|
|
required String attendanceId,
|
|
required Uint8List bytes,
|
|
required String fileName,
|
|
required String status, // 'verified', 'unverified'
|
|
bool isCheckOut = false,
|
|
}) async {
|
|
final userId = _client.auth.currentUser!.id;
|
|
final ext = fileName.split('.').last.toLowerCase();
|
|
final prefix = isCheckOut ? 'checkout' : 'checkin';
|
|
final path = '$userId/${prefix}_$attendanceId.$ext';
|
|
await _client.storage
|
|
.from('attendance-verification')
|
|
.uploadBinary(
|
|
path,
|
|
bytes,
|
|
fileOptions: const FileOptions(upsert: true),
|
|
);
|
|
final url = _client.storage
|
|
.from('attendance-verification')
|
|
.getPublicUrl(path);
|
|
final column = isCheckOut
|
|
? 'check_out_verification_photo_url'
|
|
: 'check_in_verification_photo_url';
|
|
await _client
|
|
.from('attendance_logs')
|
|
.update({'verification_status': status, column: url})
|
|
.eq('id', attendanceId);
|
|
}
|
|
|
|
/// Mark an attendance log as skipped verification.
|
|
Future<void> skipVerification(String attendanceId) async {
|
|
await _client
|
|
.from('attendance_logs')
|
|
.update({'verification_status': 'skipped'})
|
|
.eq('id', attendanceId);
|
|
}
|
|
}
|