Initial Commit: Duty Schedule and Attendance Logbook
This commit is contained in:
@@ -61,10 +61,11 @@ class AdminUserController {
|
||||
required String userId,
|
||||
required String fullName,
|
||||
required String role,
|
||||
String religion = 'catholic',
|
||||
}) async {
|
||||
await _client
|
||||
.from('profiles')
|
||||
.update({'full_name': fullName, 'role': role})
|
||||
.update({'full_name': fullName, 'role': role, 'religion': religion})
|
||||
.eq('id', userId);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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.subtract(const Duration(days: 7)),
|
||||
end: today.add(const Duration(days: 1)),
|
||||
label: 'Last 7 Days',
|
||||
);
|
||||
});
|
||||
|
||||
/// 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 == '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,
|
||||
}) async {
|
||||
await _client.rpc(
|
||||
'attendance_check_out',
|
||||
params: {'p_attendance_id': attendanceId, 'p_lat': lat, 'p_lng': lng},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../models/chat_message.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
|
||||
/// Real-time chat messages for a swap request thread.
|
||||
final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
|
||||
ref,
|
||||
threadId,
|
||||
) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<ChatMessage>(
|
||||
stream: client
|
||||
.from('chat_messages')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('thread_id', threadId)
|
||||
.order('created_at'),
|
||||
onPollData: () async {
|
||||
final data = await client
|
||||
.from('chat_messages')
|
||||
.select()
|
||||
.eq('thread_id', threadId)
|
||||
.order('created_at');
|
||||
return data.map(ChatMessage.fromMap).toList();
|
||||
},
|
||||
fromMap: ChatMessage.fromMap,
|
||||
channelName: 'chat_messages_$threadId',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
});
|
||||
|
||||
final chatControllerProvider = Provider<ChatController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return ChatController(client);
|
||||
});
|
||||
|
||||
class ChatController {
|
||||
ChatController(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<void> sendMessage({
|
||||
required String threadId,
|
||||
required String body,
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
await _client.from('chat_messages').insert({
|
||||
'thread_id': threadId,
|
||||
'sender_id': userId,
|
||||
'body': body,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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 wrapper = StreamRecoveryWrapper<PassSlip>(
|
||||
stream: isAdmin
|
||||
? 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 = isAdmin
|
||||
? 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,
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
await _client.from('pass_slips').insert({
|
||||
'user_id': userId,
|
||||
'duty_schedule_id': dutyScheduleId,
|
||||
'reason': reason,
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
'status': 'approved',
|
||||
'approved_by': userId,
|
||||
'approved_at': DateTime.now().toUtc().toIso8601String(),
|
||||
'slip_start': DateTime.now().toUtc().toIso8601String(),
|
||||
})
|
||||
.eq('id', slipId);
|
||||
}
|
||||
|
||||
Future<void> rejectSlip(String slipId) async {
|
||||
await _client
|
||||
.from('pass_slips')
|
||||
.update({
|
||||
'status': 'rejected',
|
||||
'approved_by': _client.auth.currentUser?.id,
|
||||
'approved_at': DateTime.now().toUtc().toIso8601String(),
|
||||
})
|
||||
.eq('id', slipId);
|
||||
}
|
||||
|
||||
Future<void> completeSlip(String slipId) async {
|
||||
await _client
|
||||
.from('pass_slips')
|
||||
.update({
|
||||
'status': 'completed',
|
||||
'slip_end': DateTime.now().toUtc().toIso8601String(),
|
||||
})
|
||||
.eq('id', slipId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../models/app_settings.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
/// Fetches the Ramadan configuration from app_settings.
|
||||
final ramadanConfigProvider = FutureProvider<RamadanConfig>((ref) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final data = await client
|
||||
.from('app_settings')
|
||||
.select()
|
||||
.eq('key', 'ramadan_mode')
|
||||
.maybeSingle();
|
||||
if (data == null) return RamadanConfig(enabled: false, autoDetect: true);
|
||||
final setting = AppSetting.fromMap(data);
|
||||
return RamadanConfig.fromJson(setting.value);
|
||||
});
|
||||
|
||||
/// Whether Ramadan mode is currently active.
|
||||
/// Combines manual toggle with auto-detection via Hijri calendar approximation.
|
||||
final isRamadanActiveProvider = Provider<bool>((ref) {
|
||||
final config = ref.watch(ramadanConfigProvider).valueOrNull;
|
||||
if (config == null) return false;
|
||||
|
||||
// Manual override takes priority
|
||||
if (config.enabled) return true;
|
||||
|
||||
// Auto-detect based on approximate Hijri calendar
|
||||
if (config.autoDetect) {
|
||||
return isApproximateRamadan(DateTime.now());
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
/// Approximate Ramadan detection using a simplified Hijri calendar calculation.
|
||||
/// Ramadan moves ~11 days earlier each Gregorian year.
|
||||
/// This provides a reasonable approximation; for exact dates, a Hijri calendar
|
||||
/// package could be used.
|
||||
bool isApproximateRamadan(DateTime now) {
|
||||
// Known Ramadan start dates (approximate):
|
||||
// 2025: Feb 28 - Mar 30
|
||||
// 2026: Feb 17 - Mar 19
|
||||
// 2027: Feb 7 - Mar 8
|
||||
// 2028: Jan 27 - Feb 25
|
||||
final ramadanWindows = {
|
||||
2025: (start: DateTime(2025, 2, 28), end: DateTime(2025, 3, 30)),
|
||||
2026: (start: DateTime(2026, 2, 17), end: DateTime(2026, 3, 19)),
|
||||
2027: (start: DateTime(2027, 2, 7), end: DateTime(2027, 3, 8)),
|
||||
2028: (start: DateTime(2028, 1, 27), end: DateTime(2028, 2, 25)),
|
||||
};
|
||||
|
||||
final window = ramadanWindows[now.year];
|
||||
if (window != null) {
|
||||
return !now.isBefore(window.start) && !now.isAfter(window.end);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
final ramadanControllerProvider = Provider<RamadanController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return RamadanController(client);
|
||||
});
|
||||
|
||||
class RamadanController {
|
||||
RamadanController(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
final current = await _client
|
||||
.from('app_settings')
|
||||
.select()
|
||||
.eq('key', 'ramadan_mode')
|
||||
.maybeSingle();
|
||||
|
||||
final value = current != null
|
||||
? Map<String, dynamic>.from(current['value'] as Map)
|
||||
: <String, dynamic>{'auto_detect': true};
|
||||
value['enabled'] = enabled;
|
||||
|
||||
await _client.from('app_settings').upsert({
|
||||
'key': 'ramadan_mode',
|
||||
'value': value,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setAutoDetect(bool autoDetect) async {
|
||||
final current = await _client
|
||||
.from('app_settings')
|
||||
.select()
|
||||
.eq('key', 'ramadan_mode')
|
||||
.maybeSingle();
|
||||
|
||||
final value = current != null
|
||||
? Map<String, dynamic>.from(current['value'] as Map)
|
||||
: <String, dynamic>{'enabled': false};
|
||||
value['auto_detect'] = autoDetect;
|
||||
|
||||
await _client.from('app_settings').upsert({
|
||||
'key': 'ramadan_mode',
|
||||
'value': value,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../models/live_position.dart';
|
||||
import '../services/background_location_service.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
|
||||
/// All live positions of tracked users.
|
||||
final livePositionsProvider = StreamProvider<List<LivePosition>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<LivePosition>(
|
||||
stream: client.from('live_positions').stream(primaryKey: ['user_id']),
|
||||
onPollData: () async {
|
||||
final data = await client.from('live_positions').select();
|
||||
return data.map(LivePosition.fromMap).toList();
|
||||
},
|
||||
fromMap: LivePosition.fromMap,
|
||||
channelName: 'live_positions',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
});
|
||||
|
||||
final whereaboutsControllerProvider = Provider<WhereaboutsController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return WhereaboutsController(client);
|
||||
});
|
||||
|
||||
class WhereaboutsController {
|
||||
WhereaboutsController(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
/// Upsert current position. Returns `in_premise` status.
|
||||
Future<bool> updatePosition(double lat, double lng) async {
|
||||
final data = await _client.rpc(
|
||||
'update_live_position',
|
||||
params: {'p_lat': lat, 'p_lng': lng},
|
||||
);
|
||||
return data as bool? ?? false;
|
||||
}
|
||||
|
||||
/// Toggle allow_tracking preference.
|
||||
Future<void> setTracking(bool allow) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
await _client
|
||||
.from('profiles')
|
||||
.update({'allow_tracking': allow})
|
||||
.eq('id', userId);
|
||||
|
||||
// Start or stop background location updates
|
||||
if (allow) {
|
||||
await startBackgroundLocationUpdates();
|
||||
} else {
|
||||
await stopBackgroundLocationUpdates();
|
||||
// Remove the live position entry
|
||||
await _client.from('live_positions').delete().eq('user_id', userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background location reporting service.
|
||||
/// Starts a 1-minute periodic timer that reports position to the server.
|
||||
final locationReportingProvider =
|
||||
Provider.autoDispose<LocationReportingService>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final profile = profileAsync.valueOrNull;
|
||||
|
||||
final service = LocationReportingService(client);
|
||||
|
||||
// Auto-start if user has tracking enabled
|
||||
if (profile != null && profile.allowTracking) {
|
||||
service.start();
|
||||
// Also ensure background task is registered
|
||||
startBackgroundLocationUpdates();
|
||||
}
|
||||
|
||||
ref.onDispose(service.stop);
|
||||
return service;
|
||||
});
|
||||
|
||||
class LocationReportingService {
|
||||
LocationReportingService(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
Timer? _timer;
|
||||
bool _isRunning = false;
|
||||
|
||||
bool get isRunning => _isRunning;
|
||||
|
||||
void start() {
|
||||
if (_isRunning) return;
|
||||
_isRunning = true;
|
||||
// Report immediately, then every 60 seconds
|
||||
_reportPosition();
|
||||
_timer = Timer.periodic(const Duration(seconds: 60), (_) {
|
||||
_reportPosition();
|
||||
});
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_isRunning = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
Future<void> _reportPosition() async {
|
||||
try {
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) return;
|
||||
|
||||
var permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied ||
|
||||
permission == LocationPermission.deniedForever) {
|
||||
return;
|
||||
}
|
||||
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
),
|
||||
);
|
||||
|
||||
await _client.rpc(
|
||||
'update_live_position',
|
||||
params: {'p_lat': position.latitude, 'p_lng': position.longitude},
|
||||
);
|
||||
} catch (_) {
|
||||
// Silently ignore errors in background reporting
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ final geofenceProvider = FutureProvider<GeofenceConfig?>((ref) async {
|
||||
return GeofenceConfig.fromJson(setting.value);
|
||||
});
|
||||
|
||||
/// Toggle to show/hide past schedules. Defaults to false (hide past).
|
||||
final showPastSchedulesProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
@@ -29,24 +32,17 @@ final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
|
||||
return Stream.value(const <DutySchedule>[]);
|
||||
}
|
||||
|
||||
final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher';
|
||||
|
||||
// All roles now see all schedules (RLS updated in migration)
|
||||
final wrapper = StreamRecoveryWrapper<DutySchedule>(
|
||||
stream: isAdmin
|
||||
? client
|
||||
.from('duty_schedules')
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('start_time')
|
||||
: client
|
||||
.from('duty_schedules')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('user_id', profile.id)
|
||||
.order('start_time'),
|
||||
stream: client
|
||||
.from('duty_schedules')
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('start_time'),
|
||||
onPollData: () async {
|
||||
final query = client.from('duty_schedules').select();
|
||||
final data = isAdmin
|
||||
? await query.order('start_time')
|
||||
: await query.eq('user_id', profile.id).order('start_time');
|
||||
final data = await client
|
||||
.from('duty_schedules')
|
||||
.select()
|
||||
.order('start_time');
|
||||
return data.map(DutySchedule.fromMap).toList();
|
||||
},
|
||||
fromMap: DutySchedule.fromMap,
|
||||
@@ -223,6 +219,24 @@ class WorkforceController {
|
||||
.eq('id', swapId);
|
||||
}
|
||||
|
||||
Future<void> updateSchedule({
|
||||
required String scheduleId,
|
||||
required String userId,
|
||||
required String shiftType,
|
||||
required DateTime startTime,
|
||||
required DateTime endTime,
|
||||
}) async {
|
||||
await _client
|
||||
.from('duty_schedules')
|
||||
.update({
|
||||
'user_id': userId,
|
||||
'shift_type': shiftType,
|
||||
'start_time': startTime.toUtc().toIso8601String(),
|
||||
'end_time': endTime.toUtc().toIso8601String(),
|
||||
})
|
||||
.eq('id', scheduleId);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime value) {
|
||||
final date = DateTime(value.year, value.month, value.day);
|
||||
final month = date.month.toString().padLeft(2, '0');
|
||||
|
||||
Reference in New Issue
Block a user