1067 lines
35 KiB
Dart
1067 lines
35 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../brick/cache_helpers.dart';
|
|
import '../models/it_service_request.dart';
|
|
import '../models/it_service_request_assignment.dart';
|
|
import '../models/it_service_request_activity_log.dart';
|
|
import '../models/it_service_request_action.dart';
|
|
import '../utils/app_time.dart';
|
|
import '../utils/snackbar.dart' show isOfflineSaveError;
|
|
import 'connectivity_provider.dart';
|
|
import 'profile_provider.dart';
|
|
import 'supabase_provider.dart';
|
|
import 'user_offices_provider.dart';
|
|
import 'stream_recovery.dart';
|
|
import 'realtime_controller.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Query parameters
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class ItServiceRequestQuery {
|
|
const ItServiceRequestQuery({
|
|
this.offset = 0,
|
|
this.limit = 50,
|
|
this.searchQuery = '',
|
|
this.officeId,
|
|
this.status,
|
|
this.dateRange,
|
|
});
|
|
|
|
final int offset;
|
|
final int limit;
|
|
final String searchQuery;
|
|
final String? officeId;
|
|
final String? status;
|
|
final DateTimeRange? dateRange;
|
|
|
|
ItServiceRequestQuery copyWith({
|
|
int? offset,
|
|
int? limit,
|
|
String? searchQuery,
|
|
String? officeId,
|
|
String? status,
|
|
DateTimeRange? dateRange,
|
|
}) {
|
|
return ItServiceRequestQuery(
|
|
offset: offset ?? this.offset,
|
|
limit: limit ?? this.limit,
|
|
searchQuery: searchQuery ?? this.searchQuery,
|
|
officeId: officeId ?? this.officeId,
|
|
status: status ?? this.status,
|
|
dateRange: dateRange ?? this.dateRange,
|
|
);
|
|
}
|
|
}
|
|
|
|
final itServiceRequestQueryProvider = StateProvider<ItServiceRequestQuery>(
|
|
(ref) => const ItServiceRequestQuery(),
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Offline-pending state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// IT Service Requests created offline (typed model — merged into live stream).
|
|
final offlinePendingItServiceRequestsProvider =
|
|
StateProvider<List<ItServiceRequest>>((ref) => const []);
|
|
|
|
/// Raw RPC params for offline-created requests — used for reconnect replay.
|
|
final offlinePendingItServiceRequestRawProvider =
|
|
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
|
|
|
/// Field-level updates to existing requests made offline, keyed by request id.
|
|
final offlinePendingItServiceRequestUpdatesProvider =
|
|
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
|
|
|
/// Pending action-taken entries submitted offline.
|
|
/// Each entry: {id, request_id, user_id, action_taken}
|
|
final offlinePendingIsrActionsProvider =
|
|
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stream providers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
final itServiceRequestsProvider = StreamProvider<List<ItServiceRequest>>((ref) {
|
|
final userId = ref.watch(currentUserIdProvider);
|
|
if (userId == null) return const Stream.empty();
|
|
final client = ref.watch(supabaseClientProvider);
|
|
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
|
final userOfficesAsync = ref.watch(userOfficesProvider);
|
|
|
|
final pendingNew = ref.watch(offlinePendingItServiceRequestsProvider);
|
|
final pendingUpdates =
|
|
ref.watch(offlinePendingItServiceRequestUpdatesProvider);
|
|
|
|
List<ItServiceRequest> applyPending(List<ItServiceRequest> rows) {
|
|
final byId = {for (final r in rows) r.id: r};
|
|
pendingUpdates.forEach((id, fields) {
|
|
final existing = byId[id];
|
|
if (existing == null) return;
|
|
byId[id] = _applyUpdateFields(existing, fields);
|
|
});
|
|
for (final p in pendingNew) {
|
|
byId.putIfAbsent(p.id, () => p);
|
|
}
|
|
return byId.values.toList()
|
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
}
|
|
|
|
final wrapper = StreamRecoveryWrapper<ItServiceRequest>(
|
|
stream: client
|
|
.from('it_service_requests')
|
|
.stream(primaryKey: ['id'])
|
|
.order('created_at', ascending: false),
|
|
onPollData: () async {
|
|
final data = await client
|
|
.from('it_service_requests')
|
|
.select()
|
|
.order('created_at', ascending: false)
|
|
.range(0, 199);
|
|
return data.map(ItServiceRequest.fromMap).toList();
|
|
},
|
|
fromMap: ItServiceRequest.fromMap,
|
|
channelName: 'it_service_requests',
|
|
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
|
onOfflineData: () async {
|
|
final all = await cachedListFromBrick<ItServiceRequest>();
|
|
all.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
return applyPending(all);
|
|
},
|
|
onCacheMirror: (rows) =>
|
|
mirrorBatchToBrick<ItServiceRequest>(rows, tag: 'it_service_requests'),
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
|
|
// Reconnect-replay: POST queued items directly, bypassing Brick's queue.
|
|
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
|
if (wasOnline == true || !isNowOnline) return;
|
|
|
|
// 1. Replay offline-created requests (via RPC)
|
|
final pendingRaw = List<Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingItServiceRequestRawProvider),
|
|
);
|
|
if (pendingRaw.isNotEmpty) {
|
|
debugPrint(
|
|
'[itServiceRequestsProvider] reconnected — syncing ${pendingRaw.length} request(s)',
|
|
);
|
|
final syncedIds = <String>[];
|
|
for (final params in pendingRaw) {
|
|
final id = params['p_id'] as String;
|
|
try {
|
|
await client.rpc(
|
|
'insert_it_service_request_with_number',
|
|
params: params,
|
|
);
|
|
syncedIds.add(id);
|
|
} catch (e) {
|
|
final msg = e.toString();
|
|
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
|
syncedIds.add(id);
|
|
} else {
|
|
debugPrint(
|
|
'[itServiceRequestsProvider] failed to sync id=$id: $e',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (syncedIds.isNotEmpty) {
|
|
final remainingRaw = ref
|
|
.read(offlinePendingItServiceRequestRawProvider)
|
|
.where((p) => !syncedIds.contains(p['p_id'] as String))
|
|
.toList();
|
|
ref.read(offlinePendingItServiceRequestRawProvider.notifier).state =
|
|
List.unmodifiable(remainingRaw);
|
|
|
|
final remainingModels = ref
|
|
.read(offlinePendingItServiceRequestsProvider)
|
|
.where((r) => !syncedIds.contains(r.id))
|
|
.toList();
|
|
ref.read(offlinePendingItServiceRequestsProvider.notifier).state =
|
|
List.unmodifiable(remainingModels);
|
|
}
|
|
}
|
|
|
|
// 2. Replay offline field/status updates
|
|
final pendingUpdatesMap = Map<String, Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingItServiceRequestUpdatesProvider),
|
|
);
|
|
if (pendingUpdatesMap.isNotEmpty) {
|
|
debugPrint(
|
|
'[itServiceRequestsProvider] reconnected — syncing ${pendingUpdatesMap.length} update(s)',
|
|
);
|
|
final syncedIds = <String>[];
|
|
for (final entry in pendingUpdatesMap.entries) {
|
|
try {
|
|
await client
|
|
.from('it_service_requests')
|
|
.update(entry.value)
|
|
.eq('id', entry.key);
|
|
syncedIds.add(entry.key);
|
|
} catch (e) {
|
|
debugPrint(
|
|
'[itServiceRequestsProvider] failed to sync update id=${entry.key}: $e',
|
|
);
|
|
}
|
|
}
|
|
if (syncedIds.isNotEmpty) {
|
|
final remaining = Map<String, Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingItServiceRequestUpdatesProvider),
|
|
);
|
|
for (final id in syncedIds) {
|
|
remaining.remove(id);
|
|
}
|
|
ref.read(offlinePendingItServiceRequestUpdatesProvider.notifier).state =
|
|
remaining;
|
|
}
|
|
}
|
|
|
|
// 3. Replay pending action-taken entries
|
|
final pendingActions = List<Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingIsrActionsProvider),
|
|
);
|
|
if (pendingActions.isNotEmpty) {
|
|
debugPrint(
|
|
'[itServiceRequestsProvider] reconnected — syncing ${pendingActions.length} action(s)',
|
|
);
|
|
final syncedIds = <String>[];
|
|
for (final action in pendingActions) {
|
|
final id = action['id'] as String;
|
|
try {
|
|
await client
|
|
.from('it_service_request_actions')
|
|
.upsert(action, onConflict: 'request_id,user_id');
|
|
syncedIds.add(id);
|
|
} catch (e) {
|
|
final msg = e.toString();
|
|
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
|
syncedIds.add(id);
|
|
} else {
|
|
debugPrint(
|
|
'[itServiceRequestsProvider] failed to sync action id=$id: $e',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (syncedIds.isNotEmpty) {
|
|
final remaining = ref
|
|
.read(offlinePendingIsrActionsProvider)
|
|
.where((a) => !syncedIds.contains(a['id'] as String))
|
|
.toList();
|
|
ref.read(offlinePendingIsrActionsProvider.notifier).state =
|
|
List.unmodifiable(remaining);
|
|
}
|
|
}
|
|
});
|
|
|
|
return wrapper.stream.map((result) {
|
|
var items = applyPending(result.data);
|
|
if (profile != null && profile.role == 'standard') {
|
|
final officeIds = (userOfficesAsync.valueOrNull ?? [])
|
|
.where((a) => a.userId == userId)
|
|
.map((a) => a.officeId)
|
|
.toSet();
|
|
items = items
|
|
.where(
|
|
(r) =>
|
|
r.creatorId == userId ||
|
|
(r.officeId != null && officeIds.contains(r.officeId)),
|
|
)
|
|
.toList();
|
|
}
|
|
return items;
|
|
});
|
|
});
|
|
|
|
final itServiceRequestByIdProvider = Provider.family<ItServiceRequest?, String>(
|
|
(ref, id) {
|
|
final requests = ref.watch(itServiceRequestsProvider).valueOrNull;
|
|
if (requests == null) return null;
|
|
try {
|
|
return requests.firstWhere((r) => r.id == id);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
},
|
|
);
|
|
|
|
final itServiceRequestAssignmentsProvider =
|
|
StreamProvider<List<ItServiceRequestAssignment>>((ref) {
|
|
final userId = ref.watch(currentUserIdProvider);
|
|
if (userId == null) return const Stream.empty();
|
|
final client = ref.watch(supabaseClientProvider);
|
|
|
|
final wrapper = StreamRecoveryWrapper<ItServiceRequestAssignment>(
|
|
stream: client
|
|
.from('it_service_request_assignments')
|
|
.stream(primaryKey: ['id'])
|
|
.order('created_at', ascending: false),
|
|
onPollData: () async {
|
|
final data = await client
|
|
.from('it_service_request_assignments')
|
|
.select()
|
|
.order('created_at', ascending: false);
|
|
return data.map(ItServiceRequestAssignment.fromMap).toList();
|
|
},
|
|
fromMap: ItServiceRequestAssignment.fromMap,
|
|
channelName: 'it_service_request_assignments',
|
|
onStatusChanged: ref
|
|
.read(realtimeControllerProvider)
|
|
.handleChannelStatus,
|
|
onOfflineData: () async {
|
|
final all = await cachedListFromBrick<ItServiceRequestAssignment>();
|
|
all.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
return all;
|
|
},
|
|
onCacheMirror: (rows) => mirrorBatchToBrick<ItServiceRequestAssignment>(
|
|
rows,
|
|
tag: 'isr_assignments',
|
|
),
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
return wrapper.stream.map((result) => result.data);
|
|
});
|
|
|
|
final itServiceRequestActivityLogsProvider =
|
|
StreamProvider.family<List<ItServiceRequestActivityLog>, String>((
|
|
ref,
|
|
requestId,
|
|
) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
|
|
final wrapper = StreamRecoveryWrapper<ItServiceRequestActivityLog>(
|
|
stream: client
|
|
.from('it_service_request_activity_logs')
|
|
.stream(primaryKey: ['id'])
|
|
.eq('request_id', requestId)
|
|
.order('created_at', ascending: false),
|
|
onPollData: () async {
|
|
final data = await client
|
|
.from('it_service_request_activity_logs')
|
|
.select()
|
|
.eq('request_id', requestId)
|
|
.order('created_at', ascending: false);
|
|
return data.map(ItServiceRequestActivityLog.fromMap).toList();
|
|
},
|
|
fromMap: ItServiceRequestActivityLog.fromMap,
|
|
channelName: 'isr_activity_logs_$requestId',
|
|
onStatusChanged: ref
|
|
.read(realtimeControllerProvider)
|
|
.handleChannelStatus,
|
|
onOfflineData: () async {
|
|
final all = await cachedListFromBrick<ItServiceRequestActivityLog>();
|
|
final filtered = all
|
|
.where((l) => l.requestId == requestId)
|
|
.toList()
|
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
return filtered;
|
|
},
|
|
onCacheMirror: (rows) =>
|
|
mirrorBatchToBrick<ItServiceRequestActivityLog>(
|
|
rows,
|
|
tag: 'isr_activity_logs',
|
|
),
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
return wrapper.stream.map((result) => result.data);
|
|
});
|
|
|
|
final itServiceRequestActionsProvider =
|
|
StreamProvider.family<List<ItServiceRequestAction>, String>((
|
|
ref,
|
|
requestId,
|
|
) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
|
|
final wrapper = StreamRecoveryWrapper<ItServiceRequestAction>(
|
|
stream: client
|
|
.from('it_service_request_actions')
|
|
.stream(primaryKey: ['id'])
|
|
.eq('request_id', requestId)
|
|
.order('created_at', ascending: false),
|
|
onPollData: () async {
|
|
final data = await client
|
|
.from('it_service_request_actions')
|
|
.select()
|
|
.eq('request_id', requestId)
|
|
.order('created_at', ascending: false);
|
|
return data.map(ItServiceRequestAction.fromMap).toList();
|
|
},
|
|
fromMap: ItServiceRequestAction.fromMap,
|
|
channelName: 'isr_actions_$requestId',
|
|
onStatusChanged: ref
|
|
.read(realtimeControllerProvider)
|
|
.handleChannelStatus,
|
|
onOfflineData: () async {
|
|
final all = await cachedListFromBrick<ItServiceRequestAction>();
|
|
final filtered = all
|
|
.where((a) => a.requestId == requestId)
|
|
.toList()
|
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
return filtered;
|
|
},
|
|
onCacheMirror: (rows) => mirrorBatchToBrick<ItServiceRequestAction>(
|
|
rows,
|
|
tag: 'isr_actions',
|
|
),
|
|
);
|
|
|
|
ref.onDispose(wrapper.dispose);
|
|
return wrapper.stream.map((result) => result.data);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Controller
|
|
// ---------------------------------------------------------------------------
|
|
|
|
final itServiceRequestControllerProvider = Provider<ItServiceRequestController>(
|
|
(ref) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
return ItServiceRequestController(client, ref);
|
|
},
|
|
);
|
|
|
|
class ItServiceRequestController {
|
|
ItServiceRequestController(this._client, [this._ref]);
|
|
final SupabaseClient _client;
|
|
final Ref? _ref;
|
|
|
|
/// Creates a new IT Service Request with auto-generated number.
|
|
///
|
|
/// Returns `{'id': ..., 'request_number': ...}` on success. If offline,
|
|
/// queues locally and returns `{'id': localId, 'request_number': null, 'pending': true}`.
|
|
Future<Map<String, dynamic>> createRequest({
|
|
required String eventName,
|
|
required List<String> services,
|
|
String? servicesOther,
|
|
String? officeId,
|
|
String? requestedBy,
|
|
String? requestedByUserId,
|
|
String status = 'draft',
|
|
}) async {
|
|
final userId = _client.auth.currentUser?.id;
|
|
if (userId == null) throw Exception('Not authenticated');
|
|
|
|
final offlineId = const Uuid().v4();
|
|
final rpcParams = {
|
|
'p_id': offlineId,
|
|
'p_event_name': eventName,
|
|
'p_services': services,
|
|
'p_creator_id': userId,
|
|
'p_office_id': officeId,
|
|
'p_requested_by': requestedBy,
|
|
'p_requested_by_user_id': requestedByUserId,
|
|
'p_status': status,
|
|
};
|
|
|
|
try {
|
|
final result = await _client.rpc(
|
|
'insert_it_service_request_with_number',
|
|
params: rpcParams,
|
|
);
|
|
|
|
final row = result is List
|
|
? (result.first as Map<String, dynamic>)
|
|
: result;
|
|
final requestId = row['id'] as String;
|
|
|
|
if (servicesOther != null && servicesOther.isNotEmpty) {
|
|
await _client
|
|
.from('it_service_requests')
|
|
.update({'services_other': servicesOther})
|
|
.eq('id', requestId);
|
|
}
|
|
|
|
await _client.from('it_service_request_activity_logs').insert({
|
|
'request_id': requestId,
|
|
'actor_id': userId,
|
|
'action_type': 'created',
|
|
});
|
|
|
|
return {'id': requestId, 'request_number': row['request_number']};
|
|
} catch (e) {
|
|
if (!isOfflineSaveError(e)) {
|
|
debugPrint('createRequest error: $e');
|
|
rethrow;
|
|
}
|
|
|
|
final now = DateTime.now().toUtc();
|
|
final local = ItServiceRequest(
|
|
id: offlineId,
|
|
services: services,
|
|
eventName: eventName,
|
|
status: status,
|
|
outsidePremiseAllowed: false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
creatorId: userId,
|
|
officeId: officeId,
|
|
requestedBy: requestedBy,
|
|
requestedByUserId: requestedByUserId,
|
|
servicesOther: servicesOther,
|
|
);
|
|
|
|
_queuePendingRequest(local, rpcParams);
|
|
debugPrint(
|
|
'[ItServiceRequestController] createRequest queued offline: $offlineId',
|
|
);
|
|
return {'id': offlineId, 'request_number': null, 'pending': true};
|
|
}
|
|
}
|
|
|
|
/// Updates IT Service Request fields. Offline: queues the field patch.
|
|
Future<void> updateRequest({
|
|
required String requestId,
|
|
String? eventName,
|
|
List<String>? services,
|
|
String? servicesOther,
|
|
String? eventDetails,
|
|
DateTime? eventDate,
|
|
DateTime? eventEndDate,
|
|
DateTime? dryRunDate,
|
|
DateTime? dryRunEndDate,
|
|
String? contactPerson,
|
|
String? contactNumber,
|
|
String? remarks,
|
|
String? officeId,
|
|
String? requestedBy,
|
|
String? requestedByUserId,
|
|
String? approvedBy,
|
|
String? approvedByUserId,
|
|
bool? outsidePremiseAllowed,
|
|
DateTime? dateTimeReceived,
|
|
DateTime? dateTimeChecked,
|
|
}) async {
|
|
final updates = <String, dynamic>{};
|
|
if (eventName != null) updates['event_name'] = eventName;
|
|
if (services != null) updates['services'] = services;
|
|
if (servicesOther != null) updates['services_other'] = servicesOther;
|
|
if (eventDetails != null) updates['event_details'] = eventDetails;
|
|
if (eventDate != null) updates['event_date'] = eventDate.toIso8601String();
|
|
if (eventEndDate != null) {
|
|
updates['event_end_date'] = eventEndDate.toIso8601String();
|
|
}
|
|
if (dryRunDate != null) {
|
|
updates['dry_run_date'] = dryRunDate.toIso8601String();
|
|
}
|
|
if (dryRunEndDate != null) {
|
|
updates['dry_run_end_date'] = dryRunEndDate.toIso8601String();
|
|
}
|
|
if (contactPerson != null) updates['contact_person'] = contactPerson;
|
|
if (contactNumber != null) updates['contact_number'] = contactNumber;
|
|
if (remarks != null) updates['remarks'] = remarks;
|
|
if (officeId != null) updates['office_id'] = officeId;
|
|
if (requestedBy != null) updates['requested_by'] = requestedBy;
|
|
if (requestedByUserId != null) {
|
|
updates['requested_by_user_id'] = requestedByUserId;
|
|
}
|
|
if (approvedBy != null) updates['approved_by'] = approvedBy;
|
|
if (approvedByUserId != null) {
|
|
updates['approved_by_user_id'] = approvedByUserId;
|
|
}
|
|
if (outsidePremiseAllowed != null) {
|
|
updates['outside_premise_allowed'] = outsidePremiseAllowed;
|
|
}
|
|
if (dateTimeReceived != null) {
|
|
updates['date_time_received'] = dateTimeReceived.toIso8601String();
|
|
}
|
|
if (dateTimeChecked != null) {
|
|
updates['date_time_checked'] = dateTimeChecked.toIso8601String();
|
|
}
|
|
|
|
if (updates.isEmpty) return;
|
|
|
|
try {
|
|
await _client
|
|
.from('it_service_requests')
|
|
.update(updates)
|
|
.eq('id', requestId);
|
|
|
|
final userId = _client.auth.currentUser?.id;
|
|
await _client.from('it_service_request_activity_logs').insert({
|
|
'request_id': requestId,
|
|
'actor_id': userId,
|
|
'action_type': 'updated',
|
|
'meta': {'fields': updates.keys.toList()},
|
|
});
|
|
} catch (e) {
|
|
if (!isOfflineSaveError(e)) rethrow;
|
|
_queueRequestUpdate(requestId, updates);
|
|
}
|
|
}
|
|
|
|
/// Update only the status of an IT Service Request. Offline: queues.
|
|
Future<void> updateStatus({
|
|
required String requestId,
|
|
required String status,
|
|
String? cancellationReason,
|
|
}) async {
|
|
final userId = _client.auth.currentUser?.id;
|
|
final updates = <String, dynamic>{'status': status};
|
|
|
|
if (status == 'scheduled') {
|
|
updates['approved_at'] = DateTime.now().toUtc().toIso8601String();
|
|
updates['approved_by_user_id'] = userId;
|
|
}
|
|
if (status == 'completed') {
|
|
updates['completed_at'] = DateTime.now().toUtc().toIso8601String();
|
|
}
|
|
if (status == 'cancelled') {
|
|
updates['cancelled_at'] = DateTime.now().toUtc().toIso8601String();
|
|
if (cancellationReason != null) {
|
|
updates['cancellation_reason'] = cancellationReason;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await _client
|
|
.from('it_service_requests')
|
|
.update(updates)
|
|
.eq('id', requestId);
|
|
|
|
await _client.from('it_service_request_activity_logs').insert({
|
|
'request_id': requestId,
|
|
'actor_id': userId,
|
|
'action_type': 'status_changed',
|
|
'meta': {'status': status},
|
|
});
|
|
} catch (e) {
|
|
if (!isOfflineSaveError(e)) rethrow;
|
|
_queueRequestUpdate(requestId, updates);
|
|
}
|
|
}
|
|
|
|
/// Approve a request (admin only). Offline: queues the status patch.
|
|
Future<void> approveRequest({
|
|
required String requestId,
|
|
required String approverName,
|
|
}) async {
|
|
final userId = _client.auth.currentUser?.id;
|
|
final updates = <String, dynamic>{
|
|
'status': 'scheduled',
|
|
'approved_by': approverName,
|
|
'approved_by_user_id': userId,
|
|
'approved_at': DateTime.now().toUtc().toIso8601String(),
|
|
};
|
|
|
|
try {
|
|
await _client
|
|
.from('it_service_requests')
|
|
.update(updates)
|
|
.eq('id', requestId);
|
|
|
|
await _client.from('it_service_request_activity_logs').insert({
|
|
'request_id': requestId,
|
|
'actor_id': userId,
|
|
'action_type': 'approved',
|
|
});
|
|
} catch (e) {
|
|
if (!isOfflineSaveError(e)) rethrow;
|
|
_queueRequestUpdate(requestId, updates);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Assignment management (online-only — admin/dispatcher function)
|
|
// -----------------------------------------------------------------------
|
|
|
|
Future<void> assignStaff({
|
|
required String requestId,
|
|
required List<String> userIds,
|
|
}) async {
|
|
final actorId = _client.auth.currentUser?.id;
|
|
final rows = userIds
|
|
.map((uid) => {'request_id': requestId, 'user_id': uid})
|
|
.toList();
|
|
await _client
|
|
.from('it_service_request_assignments')
|
|
.upsert(rows, onConflict: 'request_id,user_id');
|
|
|
|
await _client.from('it_service_request_activity_logs').insert({
|
|
'request_id': requestId,
|
|
'actor_id': actorId,
|
|
'action_type': 'assigned',
|
|
'meta': {'user_ids': userIds},
|
|
});
|
|
}
|
|
|
|
Future<void> unassignStaff({
|
|
required String requestId,
|
|
required String userId,
|
|
}) async {
|
|
final actorId = _client.auth.currentUser?.id;
|
|
await _client
|
|
.from('it_service_request_assignments')
|
|
.delete()
|
|
.eq('request_id', requestId)
|
|
.eq('user_id', userId);
|
|
|
|
await _client.from('it_service_request_activity_logs').insert({
|
|
'request_id': requestId,
|
|
'actor_id': actorId,
|
|
'action_type': 'unassigned',
|
|
'meta': {'user_id': userId},
|
|
});
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Action Taken — offline-safe
|
|
// -----------------------------------------------------------------------
|
|
|
|
Future<String> createOrUpdateAction({
|
|
required String requestId,
|
|
required String actionTaken,
|
|
}) async {
|
|
final userId = _client.auth.currentUser?.id;
|
|
if (userId == null) throw Exception('Not authenticated');
|
|
|
|
final actionId = const Uuid().v4();
|
|
|
|
try {
|
|
final existing = await _client
|
|
.from('it_service_request_actions')
|
|
.select('id')
|
|
.eq('request_id', requestId)
|
|
.eq('user_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (existing != null) {
|
|
await _client
|
|
.from('it_service_request_actions')
|
|
.update({'action_taken': actionTaken})
|
|
.eq('id', existing['id']);
|
|
return existing['id'] as String;
|
|
} else {
|
|
final result = await _client
|
|
.from('it_service_request_actions')
|
|
.insert({
|
|
'id': actionId,
|
|
'request_id': requestId,
|
|
'user_id': userId,
|
|
'action_taken': actionTaken,
|
|
})
|
|
.select('id')
|
|
.single();
|
|
return result['id'] as String;
|
|
}
|
|
} catch (e) {
|
|
if (!isOfflineSaveError(e)) rethrow;
|
|
_queuePendingAction({
|
|
'id': actionId,
|
|
'request_id': requestId,
|
|
'user_id': userId,
|
|
'action_taken': actionTaken,
|
|
});
|
|
return actionId;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Evidence Attachments
|
|
// -----------------------------------------------------------------------
|
|
|
|
Future<String> uploadEvidence({
|
|
required String requestId,
|
|
required String actionId,
|
|
required String fileName,
|
|
required Uint8List bytes,
|
|
DateTime? takenAt,
|
|
}) async {
|
|
final userId = _client.auth.currentUser?.id;
|
|
if (userId == null) throw Exception('Not authenticated');
|
|
|
|
final path = '$requestId/evidence/$fileName';
|
|
|
|
if (kIsWeb) {
|
|
await _client.storage
|
|
.from('it_service_attachments')
|
|
.uploadBinary(
|
|
path,
|
|
bytes,
|
|
fileOptions: const FileOptions(upsert: true),
|
|
);
|
|
} else {
|
|
final tmpDir = Directory.systemTemp;
|
|
final tmpFile = File('${tmpDir.path}/$fileName');
|
|
await tmpFile.writeAsBytes(bytes);
|
|
await _client.storage
|
|
.from('it_service_attachments')
|
|
.upload(path, tmpFile, fileOptions: const FileOptions(upsert: true));
|
|
try {
|
|
await tmpFile.delete();
|
|
} catch (_) {}
|
|
}
|
|
|
|
await _client.from('it_service_request_evidence').insert({
|
|
'request_id': requestId,
|
|
'action_id': actionId,
|
|
'user_id': userId,
|
|
'file_path': path,
|
|
'file_name': fileName,
|
|
'taken_at': takenAt?.toIso8601String(),
|
|
});
|
|
|
|
return _client.storage.from('it_service_attachments').getPublicUrl(path);
|
|
}
|
|
|
|
Future<void> deleteEvidence({required String evidenceId}) async {
|
|
final row = await _client
|
|
.from('it_service_request_evidence')
|
|
.select('file_path')
|
|
.eq('id', evidenceId)
|
|
.single();
|
|
final path = row['file_path'] as String;
|
|
|
|
await _client.storage.from('it_service_attachments').remove([path]);
|
|
await _client
|
|
.from('it_service_request_evidence')
|
|
.delete()
|
|
.eq('id', evidenceId);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// File Attachments (event files, max 25MB)
|
|
// -----------------------------------------------------------------------
|
|
|
|
Future<String> uploadAttachment({
|
|
required String requestId,
|
|
required String fileName,
|
|
required Uint8List bytes,
|
|
}) async {
|
|
if (bytes.length > 25 * 1024 * 1024) {
|
|
throw Exception('File size exceeds 25MB limit');
|
|
}
|
|
final path = '$requestId/attachments/$fileName';
|
|
|
|
if (kIsWeb) {
|
|
await _client.storage
|
|
.from('it_service_attachments')
|
|
.uploadBinary(
|
|
path,
|
|
bytes,
|
|
fileOptions: const FileOptions(upsert: true),
|
|
);
|
|
} else {
|
|
final tmpDir = Directory.systemTemp;
|
|
final tmpFile = File('${tmpDir.path}/$fileName');
|
|
await tmpFile.writeAsBytes(bytes);
|
|
await _client.storage
|
|
.from('it_service_attachments')
|
|
.upload(path, tmpFile, fileOptions: const FileOptions(upsert: true));
|
|
try {
|
|
await tmpFile.delete();
|
|
} catch (_) {}
|
|
}
|
|
|
|
return _client.storage.from('it_service_attachments').getPublicUrl(path);
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> listAttachments(String requestId) async {
|
|
try {
|
|
final files = await _client.storage
|
|
.from('it_service_attachments')
|
|
.list(path: '$requestId/attachments');
|
|
return files
|
|
.map(
|
|
(f) => {
|
|
'name': f.name,
|
|
'url': _client.storage
|
|
.from('it_service_attachments')
|
|
.getPublicUrl('$requestId/attachments/${f.name}'),
|
|
},
|
|
)
|
|
.toList();
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<void> deleteAttachment({
|
|
required String requestId,
|
|
required String fileName,
|
|
}) async {
|
|
final path = '$requestId/attachments/$fileName';
|
|
await _client.storage.from('it_service_attachments').remove([path]);
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> listEvidence(String requestId) async {
|
|
final rows = await _client
|
|
.from('it_service_request_evidence')
|
|
.select()
|
|
.eq('request_id', requestId)
|
|
.order('created_at', ascending: false);
|
|
return rows
|
|
.map(
|
|
(r) => {
|
|
'id': r['id'],
|
|
'file_name': r['file_name'],
|
|
'file_path': r['file_path'],
|
|
'taken_at': r['taken_at'],
|
|
'url': _client.storage
|
|
.from('it_service_attachments')
|
|
.getPublicUrl(r['file_path'] as String),
|
|
},
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Queue helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
void _queuePendingRequest(
|
|
ItServiceRequest model,
|
|
Map<String, dynamic> rpcParams,
|
|
) {
|
|
final ref = _ref;
|
|
if (ref == null) return;
|
|
|
|
final currentModels = List<ItServiceRequest>.from(
|
|
ref.read(offlinePendingItServiceRequestsProvider),
|
|
);
|
|
currentModels.add(model);
|
|
ref.read(offlinePendingItServiceRequestsProvider.notifier).state =
|
|
List.unmodifiable(currentModels);
|
|
|
|
final currentRaw = List<Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingItServiceRequestRawProvider),
|
|
);
|
|
currentRaw.add(rpcParams);
|
|
ref.read(offlinePendingItServiceRequestRawProvider.notifier).state =
|
|
List.unmodifiable(currentRaw);
|
|
|
|
mirrorBatchToBrick<ItServiceRequest>([model], tag: 'offline_isr');
|
|
}
|
|
|
|
void _queueRequestUpdate(String requestId, Map<String, dynamic> fields) {
|
|
final ref = _ref;
|
|
if (ref == null) return;
|
|
final current = Map<String, Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingItServiceRequestUpdatesProvider),
|
|
);
|
|
current[requestId] = {...?current[requestId], ...fields};
|
|
ref.read(offlinePendingItServiceRequestUpdatesProvider.notifier).state =
|
|
current;
|
|
}
|
|
|
|
void _queuePendingAction(Map<String, dynamic> action) {
|
|
final ref = _ref;
|
|
if (ref == null) return;
|
|
final current = List<Map<String, dynamic>>.from(
|
|
ref.read(offlinePendingIsrActionsProvider),
|
|
);
|
|
// Replace existing entry for the same request+user if present
|
|
final existing = current.indexWhere(
|
|
(a) =>
|
|
a['request_id'] == action['request_id'] &&
|
|
a['user_id'] == action['user_id'],
|
|
);
|
|
if (existing >= 0) {
|
|
current[existing] = action;
|
|
} else {
|
|
current.add(action);
|
|
}
|
|
ref.read(offlinePendingIsrActionsProvider.notifier).state =
|
|
List.unmodifiable(current);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Field-patch helper (avoids copyWith pattern on a model with 25+ fields)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
ItServiceRequest _applyUpdateFields(
|
|
ItServiceRequest r,
|
|
Map<String, dynamic> f,
|
|
) {
|
|
DateTime? parseOpt(String key) {
|
|
if (!f.containsKey(key)) return null;
|
|
final v = f[key];
|
|
return v == null ? null : AppTime.parse(v as String);
|
|
}
|
|
|
|
return ItServiceRequest(
|
|
id: r.id,
|
|
requestNumber: r.requestNumber,
|
|
services: f.containsKey('services')
|
|
? (f['services'] as List<dynamic>).cast<String>()
|
|
: r.services,
|
|
servicesOther: f.containsKey('services_other')
|
|
? f['services_other'] as String?
|
|
: r.servicesOther,
|
|
eventName: (f['event_name'] as String?) ?? r.eventName,
|
|
eventDetails: f.containsKey('event_details')
|
|
? f['event_details'] as String?
|
|
: r.eventDetails,
|
|
eventDate:
|
|
f.containsKey('event_date') ? parseOpt('event_date') : r.eventDate,
|
|
eventEndDate: f.containsKey('event_end_date')
|
|
? parseOpt('event_end_date')
|
|
: r.eventEndDate,
|
|
dryRunDate: f.containsKey('dry_run_date')
|
|
? parseOpt('dry_run_date')
|
|
: r.dryRunDate,
|
|
dryRunEndDate: f.containsKey('dry_run_end_date')
|
|
? parseOpt('dry_run_end_date')
|
|
: r.dryRunEndDate,
|
|
contactPerson: f.containsKey('contact_person')
|
|
? f['contact_person'] as String?
|
|
: r.contactPerson,
|
|
contactNumber: f.containsKey('contact_number')
|
|
? f['contact_number'] as String?
|
|
: r.contactNumber,
|
|
remarks:
|
|
f.containsKey('remarks') ? f['remarks'] as String? : r.remarks,
|
|
officeId:
|
|
f.containsKey('office_id') ? f['office_id'] as String? : r.officeId,
|
|
requestedBy: f.containsKey('requested_by')
|
|
? f['requested_by'] as String?
|
|
: r.requestedBy,
|
|
requestedByUserId: f.containsKey('requested_by_user_id')
|
|
? f['requested_by_user_id'] as String?
|
|
: r.requestedByUserId,
|
|
approvedBy: f.containsKey('approved_by')
|
|
? f['approved_by'] as String?
|
|
: r.approvedBy,
|
|
approvedByUserId: f.containsKey('approved_by_user_id')
|
|
? f['approved_by_user_id'] as String?
|
|
: r.approvedByUserId,
|
|
approvedAt: f.containsKey('approved_at')
|
|
? parseOpt('approved_at')
|
|
: r.approvedAt,
|
|
status: (f['status'] as String?) ?? r.status,
|
|
outsidePremiseAllowed:
|
|
(f['outside_premise_allowed'] as bool?) ?? r.outsidePremiseAllowed,
|
|
cancellationReason: f.containsKey('cancellation_reason')
|
|
? f['cancellation_reason'] as String?
|
|
: r.cancellationReason,
|
|
cancelledAt: f.containsKey('cancelled_at')
|
|
? parseOpt('cancelled_at')
|
|
: r.cancelledAt,
|
|
creatorId: r.creatorId,
|
|
createdAt: r.createdAt,
|
|
updatedAt: r.updatedAt,
|
|
completedAt: f.containsKey('completed_at')
|
|
? parseOpt('completed_at')
|
|
: r.completedAt,
|
|
dateTimeReceived: f.containsKey('date_time_received')
|
|
? parseOpt('date_time_received')
|
|
: r.dateTimeReceived,
|
|
dateTimeChecked: f.containsKey('date_time_checked')
|
|
? parseOpt('date_time_checked')
|
|
: r.dateTimeChecked,
|
|
);
|
|
}
|