diff --git a/lib/app.dart b/lib/app.dart index b17f1a4a..25e7233d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'routing/app_router.dart'; @@ -17,6 +19,12 @@ class TasqApp extends ConsumerWidget { theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: ThemeMode.system, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + FlutterQuillLocalizations.delegate, + ], ); } } diff --git a/lib/models/it_service_request.dart b/lib/models/it_service_request.dart new file mode 100644 index 00000000..bdd82cf3 --- /dev/null +++ b/lib/models/it_service_request.dart @@ -0,0 +1,259 @@ +import 'dart:convert'; + +import '../utils/app_time.dart'; + +/// Available IT services from the form. +class ItServiceType { + static const fbLiveStream = 'fb_live_stream'; + static const videoRecording = 'video_recording'; + static const technicalAssistance = 'technical_assistance'; + static const wifi = 'wifi'; + static const others = 'others'; + + static const all = [ + fbLiveStream, + videoRecording, + technicalAssistance, + wifi, + others, + ]; + + static String label(String type) { + switch (type) { + case fbLiveStream: + return 'FB Live Stream'; + case videoRecording: + return 'Video Recording'; + case technicalAssistance: + return 'Technical Assistance'; + case wifi: + return 'WiFi'; + case others: + return 'Others'; + default: + return type; + } + } +} + +/// Status lifecycle for an IT Service Request. +class ItServiceRequestStatus { + static const draft = 'draft'; + static const pendingApproval = 'pending_approval'; + static const scheduled = 'scheduled'; + static const inProgressDryRun = 'in_progress_dry_run'; + static const inProgress = 'in_progress'; + static const completed = 'completed'; + static const cancelled = 'cancelled'; + + static const all = [ + draft, + pendingApproval, + scheduled, + inProgressDryRun, + inProgress, + completed, + cancelled, + ]; + + static String label(String status) { + switch (status) { + case draft: + return 'Draft'; + case pendingApproval: + return 'Pending Approval'; + case scheduled: + return 'Scheduled'; + case inProgressDryRun: + return 'In Progress (Dry Run)'; + case inProgress: + return 'In Progress'; + case completed: + return 'Completed'; + case cancelled: + return 'Cancelled'; + default: + return status; + } + } +} + +class ItServiceRequest { + ItServiceRequest({ + required this.id, + this.requestNumber, + required this.services, + this.servicesOther, + required this.eventName, + this.eventDetails, + this.eventDate, + this.eventEndDate, + this.dryRunDate, + this.dryRunEndDate, + this.contactPerson, + this.contactNumber, + this.remarks, + this.officeId, + this.requestedBy, + this.requestedByUserId, + this.approvedBy, + this.approvedByUserId, + this.approvedAt, + required this.status, + required this.outsidePremiseAllowed, + this.cancellationReason, + this.cancelledAt, + this.creatorId, + required this.createdAt, + required this.updatedAt, + this.completedAt, + this.dateTimeReceived, + this.dateTimeChecked, + }); + + final String id; + final String? requestNumber; + final List services; + final String? servicesOther; + final String eventName; + final String? eventDetails; // Quill Delta JSON + final DateTime? eventDate; + final DateTime? eventEndDate; + final DateTime? dryRunDate; + final DateTime? dryRunEndDate; + final String? contactPerson; + final String? contactNumber; + final String? remarks; // Quill Delta JSON + final String? officeId; + final String? requestedBy; + final String? requestedByUserId; + final String? approvedBy; + final String? approvedByUserId; + final DateTime? approvedAt; + final String status; + final bool outsidePremiseAllowed; + final String? cancellationReason; + final DateTime? cancelledAt; + final String? creatorId; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? completedAt; + final DateTime? dateTimeReceived; + final DateTime? dateTimeChecked; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ItServiceRequest && + runtimeType == other.runtimeType && + id == other.id && + requestNumber == other.requestNumber && + status == other.status && + updatedAt == other.updatedAt; + + @override + int get hashCode => Object.hash(id, requestNumber, status, updatedAt); + + /// Whether the request is on a day that would allow geofence override. + bool isGeofenceOverrideActive(DateTime now) { + if (!outsidePremiseAllowed) return false; + final today = DateTime(now.year, now.month, now.day); + // Active on dry run date or event date + if (dryRunDate != null) { + final dryDay = DateTime( + dryRunDate!.year, + dryRunDate!.month, + dryRunDate!.day, + ); + if (!today.isBefore(dryDay) && + today.isBefore(dryDay.add(const Duration(days: 1)))) { + return true; + } + } + if (eventDate != null) { + final eventDay = DateTime( + eventDate!.year, + eventDate!.month, + eventDate!.day, + ); + if (!today.isBefore(eventDay) && + today.isBefore(eventDay.add(const Duration(days: 1)))) { + return true; + } + } + return false; + } + + factory ItServiceRequest.fromMap(Map map) { + List parseServices(dynamic raw) { + if (raw is List) return raw.map((e) => e.toString()).toList(); + if (raw is String) { + // Handle PostgreSQL array format: {a,b,c} + final trimmed = raw.replaceAll('{', '').replaceAll('}', ''); + if (trimmed.isEmpty) return []; + return trimmed.split(',').map((e) => e.trim()).toList(); + } + return []; + } + + String? quillField(dynamic raw) { + if (raw == null) return null; + if (raw is String) return raw; + try { + return jsonEncode(raw); + } catch (_) { + return raw.toString(); + } + } + + return ItServiceRequest( + id: map['id'] as String, + requestNumber: map['request_number'] as String?, + services: parseServices(map['services']), + servicesOther: map['services_other'] as String?, + eventName: map['event_name'] as String? ?? '', + eventDetails: quillField(map['event_details']), + eventDate: map['event_date'] == null + ? null + : AppTime.parse(map['event_date'] as String), + eventEndDate: map['event_end_date'] == null + ? null + : AppTime.parse(map['event_end_date'] as String), + dryRunDate: map['dry_run_date'] == null + ? null + : AppTime.parse(map['dry_run_date'] as String), + dryRunEndDate: map['dry_run_end_date'] == null + ? null + : AppTime.parse(map['dry_run_end_date'] as String), + contactPerson: map['contact_person'] as String?, + contactNumber: map['contact_number'] as String?, + remarks: quillField(map['remarks']), + officeId: map['office_id'] as String?, + requestedBy: map['requested_by'] as String?, + requestedByUserId: map['requested_by_user_id'] as String?, + approvedBy: map['approved_by'] as String?, + approvedByUserId: map['approved_by_user_id'] as String?, + approvedAt: map['approved_at'] == null + ? null + : AppTime.parse(map['approved_at'] as String), + status: map['status'] as String? ?? 'draft', + outsidePremiseAllowed: map['outside_premise_allowed'] as bool? ?? false, + cancellationReason: map['cancellation_reason'] as String?, + cancelledAt: map['cancelled_at'] == null + ? null + : AppTime.parse(map['cancelled_at'] as String), + creatorId: map['creator_id'] as String?, + createdAt: AppTime.parse(map['created_at'] as String), + updatedAt: AppTime.parse(map['updated_at'] as String), + completedAt: map['completed_at'] == null + ? null + : AppTime.parse(map['completed_at'] as String), + dateTimeReceived: map['date_time_received'] == null + ? null + : AppTime.parse(map['date_time_received'] as String), + dateTimeChecked: map['date_time_checked'] == null + ? null + : AppTime.parse(map['date_time_checked'] as String), + ); + } +} diff --git a/lib/models/it_service_request_action.dart b/lib/models/it_service_request_action.dart new file mode 100644 index 00000000..1e2a1652 --- /dev/null +++ b/lib/models/it_service_request_action.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import '../utils/app_time.dart'; + +class ItServiceRequestAction { + ItServiceRequestAction({ + required this.id, + required this.requestId, + required this.userId, + this.actionTaken, + required this.createdAt, + required this.updatedAt, + }); + + final String id; + final String requestId; + final String userId; + final String? actionTaken; // Quill Delta JSON + final DateTime createdAt; + final DateTime updatedAt; + + factory ItServiceRequestAction.fromMap(Map map) { + String? quillField(dynamic raw) { + if (raw == null) return null; + if (raw is String) return raw; + try { + return jsonEncode(raw); + } catch (_) { + return raw.toString(); + } + } + + return ItServiceRequestAction( + id: map['id'] as String, + requestId: map['request_id'] as String, + userId: map['user_id'] as String, + actionTaken: quillField(map['action_taken']), + createdAt: AppTime.parse(map['created_at'] as String), + updatedAt: AppTime.parse(map['updated_at'] as String), + ); + } +} diff --git a/lib/models/it_service_request_activity_log.dart b/lib/models/it_service_request_activity_log.dart new file mode 100644 index 00000000..a1e75ae9 --- /dev/null +++ b/lib/models/it_service_request_activity_log.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import '../utils/app_time.dart'; + +class ItServiceRequestActivityLog { + ItServiceRequestActivityLog({ + required this.id, + required this.requestId, + this.actorId, + required this.actionType, + this.meta, + required this.createdAt, + }); + + final String id; + final String requestId; + final String? actorId; + final String actionType; + final Map? meta; + final DateTime createdAt; + + factory ItServiceRequestActivityLog.fromMap(Map map) { + final rawId = map['id']; + final rawRequestId = map['request_id']; + String id = rawId == null ? '' : rawId.toString(); + String requestId = rawRequestId == null ? '' : rawRequestId.toString(); + final actorId = map['actor_id']?.toString(); + final actionType = (map['action_type'] as String?) ?? 'unknown'; + + Map? meta; + final rawMeta = map['meta']; + if (rawMeta is Map) { + meta = rawMeta; + } else if (rawMeta is Map) { + try { + meta = rawMeta.map((k, v) => MapEntry(k.toString(), v)); + } catch (_) { + meta = null; + } + } else if (rawMeta is String && rawMeta.isNotEmpty) { + try { + final decoded = jsonDecode(rawMeta); + if (decoded is Map) { + meta = decoded; + } else if (decoded is Map) { + meta = decoded.map((k, v) => MapEntry(k.toString(), v)); + } + } catch (_) { + meta = null; + } + } + + DateTime createdAt; + final rawCreated = map['created_at']; + if (rawCreated is String) { + try { + createdAt = AppTime.parse(rawCreated); + } catch (_) { + createdAt = AppTime.now(); + } + } else { + createdAt = AppTime.now(); + } + + return ItServiceRequestActivityLog( + id: id, + requestId: requestId, + actorId: actorId, + actionType: actionType, + meta: meta, + createdAt: createdAt, + ); + } +} diff --git a/lib/models/it_service_request_assignment.dart b/lib/models/it_service_request_assignment.dart new file mode 100644 index 00000000..f67c87cd --- /dev/null +++ b/lib/models/it_service_request_assignment.dart @@ -0,0 +1,24 @@ +import '../utils/app_time.dart'; + +class ItServiceRequestAssignment { + ItServiceRequestAssignment({ + required this.id, + required this.requestId, + required this.userId, + required this.createdAt, + }); + + final String id; + final String requestId; + final String userId; + final DateTime createdAt; + + factory ItServiceRequestAssignment.fromMap(Map map) { + return ItServiceRequestAssignment( + id: map['id'] as String, + requestId: map['request_id'] as String, + userId: map['user_id'] as String, + createdAt: AppTime.parse(map['created_at'] as String), + ); + } +} diff --git a/lib/models/notification_item.dart b/lib/models/notification_item.dart index 9af4bfb7..3fafba89 100644 --- a/lib/models/notification_item.dart +++ b/lib/models/notification_item.dart @@ -7,6 +7,7 @@ class NotificationItem { required this.actorId, required this.ticketId, required this.taskId, + required this.itServiceRequestId, required this.messageId, required this.type, required this.createdAt, @@ -18,6 +19,7 @@ class NotificationItem { final String? actorId; final String? ticketId; final String? taskId; + final String? itServiceRequestId; final int? messageId; final String type; final DateTime createdAt; @@ -32,6 +34,7 @@ class NotificationItem { actorId: map['actor_id'] as String?, ticketId: map['ticket_id'] as String?, taskId: map['task_id'] as String?, + itServiceRequestId: map['it_service_request_id'] as String?, messageId: map['message_id'] as int?, type: map['type'] as String? ?? 'mention', createdAt: AppTime.parse(map['created_at'] as String), diff --git a/lib/providers/it_service_request_provider.dart b/lib/providers/it_service_request_provider.dart new file mode 100644 index 00000000..22d620f0 --- /dev/null +++ b/lib/providers/it_service_request_provider.dart @@ -0,0 +1,653 @@ +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 '../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 '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( + (ref) => const ItServiceRequestQuery(), +); + +// --------------------------------------------------------------------------- +// Stream providers +// --------------------------------------------------------------------------- + +final itServiceRequestsProvider = StreamProvider>((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 wrapper = StreamRecoveryWrapper( + 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); + return data.map(ItServiceRequest.fromMap).toList(); + }, + fromMap: ItServiceRequest.fromMap, + channelName: 'it_service_requests', + onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus, + ); + + ref.onDispose(wrapper.dispose); + return wrapper.stream.map((result) { + var items = result.data; + // Standard users see only requests from their offices or created by them + 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( + (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>((ref) { + final userId = ref.watch(currentUserIdProvider); + if (userId == null) return const Stream.empty(); + final client = ref.watch(supabaseClientProvider); + + final wrapper = StreamRecoveryWrapper( + 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, + ); + + ref.onDispose(wrapper.dispose); + return wrapper.stream.map((result) => result.data); + }); + +final itServiceRequestActivityLogsProvider = + StreamProvider.family, String>(( + ref, + requestId, + ) { + final client = ref.watch(supabaseClientProvider); + + final wrapper = StreamRecoveryWrapper( + 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, + ); + + ref.onDispose(wrapper.dispose); + return wrapper.stream.map((result) => result.data); + }); + +final itServiceRequestActionsProvider = + StreamProvider.family, String>(( + ref, + requestId, + ) { + final client = ref.watch(supabaseClientProvider); + + final wrapper = StreamRecoveryWrapper( + 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, + ); + + ref.onDispose(wrapper.dispose); + return wrapper.stream.map((result) => result.data); + }); + +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + +final itServiceRequestControllerProvider = Provider( + (ref) { + final client = ref.watch(supabaseClientProvider); + return ItServiceRequestController(client); + }, +); + +class ItServiceRequestController { + ItServiceRequestController(this._client); + final SupabaseClient _client; + + /// Creates a new IT Service Request with auto-generated number. + Future> createRequest({ + required String eventName, + required List 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'); + + try { + final result = await _client.rpc( + 'insert_it_service_request_with_number', + params: { + '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, + }, + ); + + final row = result is List + ? (result.first as Map) + : 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); + } + + // Activity log + 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) { + debugPrint('createRequest error: $e'); + rethrow; + } + } + + /// Updates IT Service Request fields. + Future updateRequest({ + required String requestId, + String? eventName, + List? 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 = {}; + 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; + + await _client + .from('it_service_requests') + .update(updates) + .eq('id', requestId); + + // Log updated fields + 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()}, + }); + } + + /// Update only the status of an IT Service Request. + Future updateStatus({ + required String requestId, + required String status, + String? cancellationReason, + }) async { + final userId = _client.auth.currentUser?.id; + final updates = {'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; + } + } + + 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}, + }); + } + + /// Approve a request (admin only). Sets status to 'scheduled'. + Future approveRequest({ + required String requestId, + required String approverName, + }) async { + final userId = _client.auth.currentUser?.id; + await _client + .from('it_service_requests') + .update({ + 'status': 'scheduled', + 'approved_by': approverName, + 'approved_by_user_id': userId, + 'approved_at': DateTime.now().toUtc().toIso8601String(), + }) + .eq('id', requestId); + + await _client.from('it_service_request_activity_logs').insert({ + 'request_id': requestId, + 'actor_id': userId, + 'action_type': 'approved', + }); + } + + // ----------------------------------------------------------------------- + // Assignment management + // ----------------------------------------------------------------------- + + Future assignStaff({ + required String requestId, + required List 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 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 + // ----------------------------------------------------------------------- + + Future createOrUpdateAction({ + required String requestId, + required String actionTaken, + }) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) throw Exception('Not authenticated'); + + // Check if action already exists for this user + 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({ + 'request_id': requestId, + 'user_id': userId, + 'action_taken': actionTaken, + }) + .select('id') + .single(); + return result['id'] as String; + } + } + + // ----------------------------------------------------------------------- + // Evidence Attachments + // ----------------------------------------------------------------------- + + Future 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 deleteEvidence({required String evidenceId}) async { + // Get path first + 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 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>> 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 deleteAttachment({ + required String requestId, + required String fileName, + }) async { + final path = '$requestId/attachments/$fileName'; + await _client.storage.from('it_service_attachments').remove([path]); + } + + /// List evidence attachments for a request from the database. + Future>> 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(); + } +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 4d947451..f2c8ee06 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -27,6 +27,8 @@ import '../screens/attendance/attendance_screen.dart'; import '../screens/whereabouts/whereabouts_screen.dart'; import '../widgets/app_shell.dart'; import '../screens/teams/teams_screen.dart'; +import '../screens/it_service_requests/it_service_requests_list_screen.dart'; +import '../screens/it_service_requests/it_service_request_detail_screen.dart'; import '../theme/m3_motion.dart'; final appRouterProvider = Provider((ref) { @@ -146,15 +148,22 @@ final appRouterProvider = Provider((ref) { ], ), GoRoute( - path: '/events', + path: '/it-service-requests', pageBuilder: (context, state) => M3SharedAxisPage( key: state.pageKey, - child: const UnderDevelopmentScreen( - title: 'Events', - subtitle: 'Event monitoring is under development.', - icon: Icons.event, - ), + child: const ItServiceRequestsListScreen(), ), + routes: [ + GoRoute( + path: ':id', + pageBuilder: (context, state) => M3ContainerTransformPage( + key: state.pageKey, + child: ItServiceRequestDetailScreen( + requestId: state.pathParameters['id'] ?? '', + ), + ), + ), + ], ), GoRoute( path: '/announcements', diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 75bf3fd7..978c0908 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -23,6 +23,7 @@ import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; +import '../../providers/it_service_request_provider.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/reconnect_overlay.dart'; import '../../providers/realtime_controller.dart'; @@ -68,6 +69,7 @@ class StaffRowMetrics { required this.whereabouts, required this.ticketsRespondedToday, required this.tasksClosedToday, + required this.eventsHandledToday, }); final String userId; @@ -76,6 +78,7 @@ class StaffRowMetrics { final String whereabouts; final int ticketsRespondedToday; final int tasksClosedToday; + final int eventsHandledToday; } final dashboardMetricsProvider = Provider>((ref) { @@ -89,6 +92,8 @@ final dashboardMetricsProvider = Provider>((ref) { final positionsAsync = ref.watch(livePositionsProvider); final leavesAsync = ref.watch(leavesProvider); final passSlipsAsync = ref.watch(passSlipsProvider); + final isrAssignmentsAsync = ref.watch(itServiceRequestAssignmentsProvider); + final isrAsync = ref.watch(itServiceRequestsProvider); final asyncValues = [ ticketsAsync, @@ -424,14 +429,22 @@ final dashboardMetricsProvider = Provider>((ref) { whereabouts: whereabouts, ticketsRespondedToday: ticketsResponded, tasksClosedToday: tasksClosed, + eventsHandledToday: _countEventsHandledToday( + staff.id, + isrAssignmentsAsync.valueOrNull ?? [], + isrAsync.valueOrNull ?? [], + now, + ), ); }).toList(); // Order IT staff by combined activity (tickets responded today + tasks closed today) // descending so most-active staff appear first. Use name as a stable tiebreaker. staffRows.sort((a, b) { - final aCount = a.ticketsRespondedToday + a.tasksClosedToday; - final bCount = b.ticketsRespondedToday + b.tasksClosedToday; + final aCount = + a.ticketsRespondedToday + a.tasksClosedToday + a.eventsHandledToday; + final bCount = + b.ticketsRespondedToday + b.tasksClosedToday + b.eventsHandledToday; if (bCount != aCount) return bCount.compareTo(aCount); return a.name.compareTo(b.name); }); @@ -452,6 +465,44 @@ final dashboardMetricsProvider = Provider>((ref) { ); }); +int _countEventsHandledToday( + String userId, + List isrAssignments, + List isrList, + DateTime now, +) { + final startOfDay = DateTime(now.year, now.month, now.day); + final endOfDay = startOfDay.add(const Duration(days: 1)); + // Find all ISR IDs assigned to this user + final assignedIsrIds = {}; + for (final a in isrAssignments) { + if (a.userId == userId) { + assignedIsrIds.add(a.requestId); + } + } + if (assignedIsrIds.isEmpty) return 0; + // Count ISRs that are in active status today + int count = 0; + for (final isr in isrList) { + if (!assignedIsrIds.contains(isr.id)) continue; + if (isr.status == 'in_progress' || + isr.status == 'in_progress_dry_run' || + isr.status == 'completed') { + // Check if event date or dry run date is today + final eventToday = + isr.eventDate != null && + !isr.eventDate!.isBefore(startOfDay) && + isr.eventDate!.isBefore(endOfDay); + final dryRunToday = + isr.dryRunDate != null && + !isr.dryRunDate!.isBefore(startOfDay) && + isr.dryRunDate!.isBefore(endOfDay); + if (eventToday || dryRunToday) count++; + } + } + return count; +} + class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -821,6 +872,7 @@ class _StaffTableHeader extends StatelessWidget { Expanded(flex: 2, child: Text('Whereabouts', style: style)), Expanded(flex: 2, child: Text('Tickets', style: style)), Expanded(flex: 2, child: Text('Tasks', style: style)), + Expanded(flex: 2, child: Text('Events', style: style)), ], ); } @@ -920,6 +972,10 @@ class _StaffRow extends StatelessWidget { flex: 2, child: Text(row.tasksClosedToday.toString(), style: valueStyle), ), + Expanded( + flex: 2, + child: Text(row.eventsHandledToday.toString(), style: valueStyle), + ), ], ), ); diff --git a/lib/screens/it_service_requests/it_service_request_detail_screen.dart b/lib/screens/it_service_requests/it_service_request_detail_screen.dart new file mode 100644 index 00000000..0752b425 --- /dev/null +++ b/lib/screens/it_service_requests/it_service_request_detail_screen.dart @@ -0,0 +1,2212 @@ +// ignore_for_file: use_build_context_synchronously +import 'dart:convert'; +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:skeletonizer/skeletonizer.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 '../../models/office.dart'; +import '../../models/profile.dart'; +import '../../providers/it_service_request_provider.dart'; +import '../../providers/notifications_provider.dart'; +import '../../providers/profile_provider.dart'; +import '../../providers/supabase_provider.dart'; +import '../../providers/tickets_provider.dart'; +import '../../services/ai_service.dart'; +import '../../utils/app_time.dart'; +import '../../utils/snackbar.dart'; +import '../../widgets/app_breakpoints.dart'; +import '../../widgets/gemini_animated_text_field.dart'; +import '../../widgets/m3_card.dart'; +import '../../widgets/mono_text.dart'; +import '../../widgets/responsive_body.dart'; +import '../../widgets/status_pill.dart'; +import 'it_service_request_pdf.dart'; + +class ItServiceRequestDetailScreen extends ConsumerStatefulWidget { + const ItServiceRequestDetailScreen({super.key, required this.requestId}); + final String requestId; + + @override + ConsumerState createState() => + _ItServiceRequestDetailScreenState(); +} + +class _ItServiceRequestDetailScreenState + extends ConsumerState + with TickerProviderStateMixin { + late final TabController _tabController; + + // Form controllers + final _eventNameController = TextEditingController(); + final _contactPersonController = TextEditingController(); + final _contactNumberController = TextEditingController(); + final _requestedByController = TextEditingController(); + final _servicesOtherController = TextEditingController(); + + // Quill controllers + quill.QuillController? _eventDetailsController; + quill.QuillController? _remarksController; + quill.QuillController? _actionTakenController; + + // State + final _selectedServices = {}; + bool _outsidePremiseAllowed = false; + DateTime? _eventDate; + DateTime? _eventEndDate; + DateTime? _dryRunDate; + DateTime? _dryRunEndDate; + String? _selectedOfficeId; + bool _initialized = false; + + bool _eventDetailsProcessing = false; + bool _remarksProcessing = false; + bool _actionProcessing = false; + bool _isPrinting = false; + bool _isActionSaving = false; + bool _isActionSaved = false; + Uint8List? _cachedIsrPdfBytes; + String? _cachedIsrPdfKey; + int _pendingSaveCount = 0; + bool _hasRecentSave = false; + Timer? _savedBadgeTimer; + int? _actionSeedVersion; + + late final AnimationController _saveAnimController; + late final Animation _savePulse; + + Timer? _eventDetailsDebounce; + Timer? _remarksDebounce; + Timer? _actionDebounce; + + String _eventDetailsLastPlain = ''; + String _remarksLastPlain = ''; + String _actionLastPlain = ''; + + void _onEventDetailsChanged() { + _debounceQuillSave( + 'event_details', + _eventDetailsController, + _eventDetailsLastPlain, + (v) => _eventDetailsLastPlain = v, + _eventDetailsDebounce, + (t) => _eventDetailsDebounce = t, + ); + } + + void _onRemarksChanged() { + _debounceQuillSave( + 'remarks', + _remarksController, + _remarksLastPlain, + (v) => _remarksLastPlain = v, + _remarksDebounce, + (t) => _remarksDebounce = t, + ); + } + + void _onActionTakenChanged() { + _saveActionTaken(); + } + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + _actionTakenController = quill.QuillController.basic(); + _actionTakenController?.addListener(_onActionTakenChanged); + _saveAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _savePulse = Tween(begin: 1.0, end: 0.78).animate( + CurvedAnimation(parent: _saveAnimController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _eventNameController.dispose(); + _contactPersonController.dispose(); + _contactNumberController.dispose(); + _requestedByController.dispose(); + _servicesOtherController.dispose(); + _eventDetailsController?.removeListener(_onEventDetailsChanged); + _remarksController?.removeListener(_onRemarksChanged); + _actionTakenController?.removeListener(_onActionTakenChanged); + _eventDetailsController?.dispose(); + _remarksController?.dispose(); + _actionTakenController?.dispose(); + _eventDetailsDebounce?.cancel(); + _remarksDebounce?.cancel(); + _actionDebounce?.cancel(); + _savedBadgeTimer?.cancel(); + _saveAnimController.dispose(); + _tabController.dispose(); + super.dispose(); + } + + bool get _anySaving => _pendingSaveCount > 0 || _isActionSaving; + + void _updateSaveAnim() { + if (_anySaving) { + if (!_saveAnimController.isAnimating) { + _saveAnimController.repeat(reverse: true); + } + } else if (_saveAnimController.isAnimating) { + _saveAnimController.stop(); + _saveAnimController.reset(); + } + } + + void _startSave() { + if (!mounted) return; + setState(() { + _pendingSaveCount += 1; + _hasRecentSave = false; + }); + _updateSaveAnim(); + } + + void _finishSave({required bool success}) { + if (!mounted) return; + setState(() { + _pendingSaveCount = (_pendingSaveCount - 1).clamp(0, 1 << 20); + if (success) _hasRecentSave = true; + }); + _updateSaveAnim(); + if (success) { + _savedBadgeTimer?.cancel(); + _savedBadgeTimer = Timer(const Duration(seconds: 2), () { + if (mounted && !_anySaving) { + setState(() => _hasRecentSave = false); + } + }); + } + } + + void _initFromRequest(ItServiceRequest request) { + if (_initialized) return; + _initialized = true; + + _eventNameController.text = request.eventName; + _contactPersonController.text = request.contactPerson ?? ''; + _contactNumberController.text = request.contactNumber ?? ''; + _requestedByController.text = request.requestedBy ?? ''; + _servicesOtherController.text = request.servicesOther ?? ''; + _selectedServices.addAll(request.services); + _outsidePremiseAllowed = request.outsidePremiseAllowed; + _eventDate = request.eventDate; + _eventEndDate = request.eventEndDate; + _dryRunDate = request.dryRunDate; + _dryRunEndDate = request.dryRunEndDate; + _selectedOfficeId = request.officeId; + + _eventDetailsController = _quillFromDelta(request.eventDetails); + _remarksController = _quillFromDelta(request.remarks); + _eventDetailsController?.removeListener(_onEventDetailsChanged); + _eventDetailsController?.addListener(_onEventDetailsChanged); + _remarksController?.removeListener(_onRemarksChanged); + _remarksController?.addListener(_onRemarksChanged); + + _eventDetailsLastPlain = + _eventDetailsController?.document.toPlainText().trim() ?? ''; + _remarksLastPlain = _remarksController?.document.toPlainText().trim() ?? ''; + } + + quill.QuillController _quillFromDelta(String? deltaJson) { + if (deltaJson != null && deltaJson.isNotEmpty) { + try { + final decoded = jsonDecode(deltaJson); + if (decoded is List) { + return quill.QuillController( + document: quill.Document.fromJson(decoded), + selection: const TextSelection.collapsed(offset: 0), + ); + } + } catch (_) {} + } + return quill.QuillController.basic(); + } + + bool get _canEdit { + final profile = ref.read(currentProfileProvider).valueOrNull; + if (profile == null) return false; + final request = ref.read(itServiceRequestByIdProvider(widget.requestId)); + if (request == null) return false; + // Admin, dispatcher can always edit; IT Staff and creator can edit in certain statuses + if (profile.role == 'admin' || profile.role == 'dispatcher') return true; + if (profile.role == 'it_staff') return true; + if (request.creatorId == profile.id && + (request.status == 'draft' || request.status == 'pending_approval')) { + return true; + } + return false; + } + + bool get _canApprove { + final profile = ref.read(currentProfileProvider).valueOrNull; + return profile?.role == 'admin'; + } + + bool get _canChangeStatus { + final profile = ref.read(currentProfileProvider).valueOrNull; + if (profile == null) return false; + if (profile.role == 'admin' || profile.role == 'dispatcher') return true; + // Assigned IT staff can change status + final assignments = + ref.read(itServiceRequestAssignmentsProvider).valueOrNull ?? []; + return assignments.any( + (a) => a.requestId == widget.requestId && a.userId == profile.id, + ); + } + + void _syncActionTakenFromServer( + List actions, + String? currentUserId, + ) { + if (currentUserId == null || _isActionSaving || _actionProcessing) return; + ItServiceRequestAction? myLatest; + for (final a in actions) { + if (a.userId != currentUserId) continue; + if (myLatest == null || a.updatedAt.isAfter(myLatest.updatedAt)) { + myLatest = a; + } + } + if (myLatest == null) return; + + final version = myLatest.updatedAt.millisecondsSinceEpoch; + if (_actionSeedVersion == version) return; + + final localPlain = + _actionTakenController?.document.toPlainText().trim() ?? ''; + if (_actionSeedVersion != null && localPlain.isNotEmpty) return; + + _actionTakenController?.removeListener(_onActionTakenChanged); + _actionTakenController = _quillFromDelta(myLatest.actionTaken); + _actionTakenController?.addListener(_onActionTakenChanged); + _actionLastPlain = + _actionTakenController?.document.toPlainText().trim() ?? ''; + _actionSeedVersion = version; + + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() {}); + }); + } + } + + Future _handlePrintPdf( + BuildContext context, { + required ItServiceRequest request, + required List assignments, + required Map profileById, + required Map officeById, + }) async { + if (_isPrinting) return; + setState(() => _isPrinting = true); + try { + // Yield one frame so the spinner paints before heavy PDF work starts. + await Future.delayed(const Duration(milliseconds: 16)); + final cacheKey = _buildIsrPdfCacheKey(request, assignments); + if (_cachedIsrPdfBytes == null || _cachedIsrPdfKey != cacheKey) { + _cachedIsrPdfBytes = await buildItServiceRequestPdfBytes( + request: request, + assignments: assignments, + profileById: profileById, + officeById: officeById, + ); + _cachedIsrPdfKey = cacheKey; + } + await generateItServiceRequestPdf( + context: context, + request: request, + assignments: assignments, + profileById: profileById, + officeById: officeById, + prebuiltBytes: _cachedIsrPdfBytes, + ); + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Failed to generate PDF: $e'); + } finally { + if (mounted) setState(() => _isPrinting = false); + } + } + + String _buildIsrPdfCacheKey( + ItServiceRequest request, + List assignments, + ) { + final sorted = [...assignments] + ..sort((a, b) => a.userId.compareTo(b.userId)); + final assigned = sorted + .map((a) => '${a.userId}:${a.createdAt.millisecondsSinceEpoch}') + .join('|'); + return '${request.id}:${request.updatedAt.millisecondsSinceEpoch}:$assigned'; + } + + @override + Widget build(BuildContext context) { + final request = ref.watch(itServiceRequestByIdProvider(widget.requestId)); + final profileAsync = ref.watch(currentProfileProvider); + final profilesAsync = ref.watch(profilesProvider); + final officesAsync = ref.watch(officesProvider); + final assignmentsAsync = ref.watch(itServiceRequestAssignmentsProvider); + final activityLogsAsync = ref.watch( + itServiceRequestActivityLogsProvider(widget.requestId), + ); + final actionsAsync = ref.watch( + itServiceRequestActionsProvider(widget.requestId), + ); + + final showSkeleton = request == null && !profileAsync.hasValue; + + if (request != null) _initFromRequest(request); + + final profileById = { + for (final p in profilesAsync.valueOrNull ?? []) p.id: p, + }; + final officeById = { + for (final o in officesAsync.valueOrNull ?? []) o.id: o, + }; + final assignments = + (assignmentsAsync.valueOrNull ?? []) + .where((a) => a.requestId == widget.requestId) + .toList(); + + final isWide = MediaQuery.of(context).size.width >= AppBreakpoints.tablet; + + return Scaffold( + appBar: AppBar( + title: Text(request?.requestNumber ?? 'IT Service Request'), + actions: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + child: _anySaving + ? ScaleTransition( + key: const ValueKey('isr_saving'), + scale: _savePulse, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.save, size: 18), + ), + ) + : _hasRecentSave + ? const Padding( + key: ValueKey('isr_saved'), + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon( + Icons.check_circle, + size: 18, + color: Colors.green, + ), + ) + : const SizedBox(key: ValueKey('isr_idle')), + ), + if (request != null) + IconButton( + tooltip: 'Print PDF', + icon: _isPrinting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.print), + onPressed: _isPrinting + ? null + : () => _handlePrintPdf( + context, + request: request, + assignments: assignments, + profileById: profileById, + officeById: officeById, + ), + ), + if (_canApprove && + request?.status == ItServiceRequestStatus.pendingApproval) + FilledButton.icon( + onPressed: () => _approveRequest(context, request!), + icon: const Icon(Icons.check), + label: const Text('Approve'), + ), + if (_canChangeStatus && request != null) + PopupMenuButton( + tooltip: 'Change status', + onSelected: (status) => _changeStatus(context, request, status), + itemBuilder: (ctx) => _statusOptions(request), + ), + const SizedBox(width: 8), + ], + ), + body: Skeletonizer( + enabled: showSkeleton, + child: request == null && !showSkeleton + ? const Center(child: Text('Request not found')) + : ResponsiveBody( + maxWidth: double.infinity, + child: Column( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: (_anySaving || _isPrinting) + ? const LinearProgressIndicator( + key: ValueKey('isr_busy_progress'), + minHeight: 2, + ) + : const SizedBox( + key: ValueKey('isr_busy_idle'), + height: 2, + ), + ), + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Details'), + Tab(text: 'Action Taken'), + Tab(text: 'Activity'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + // Tab 1: Details + _buildDetailsTab( + context, + request, + assignments, + profileById, + officeById, + isWide, + ), + // Tab 2: Action Taken + _buildActionTakenTab( + context, + request, + actionsAsync.valueOrNull ?? [], + profileById, + isWide, + ), + // Tab 3: Activity + _buildActivityTab( + activityLogsAsync.valueOrNull ?? [], + profileById, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + // ------------------------------------------------------------------------- + // Details Tab + // ------------------------------------------------------------------------- + Widget _buildDetailsTab( + BuildContext context, + ItServiceRequest? request, + List assignments, + Map profileById, + Map officeById, + bool isWide, + ) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + final canEdit = _canEdit; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with status + if (request != null) + Row( + children: [ + if (request.requestNumber != null) + MonoText(request.requestNumber!), + const SizedBox(width: 12), + StatusPill(label: ItServiceRequestStatus.label(request.status)), + const Spacer(), + Text( + 'Created ${AppTime.formatDate(request.createdAt)}', + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ), + const SizedBox(height: 16), + + // Services section + M3Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Services', style: tt.titleSmall), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) { + final isMobile = + constraints.maxWidth < AppBreakpoints.tablet; + if (isMobile) { + // Mobile: full-width chips stacked vertically + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: ItServiceType.all.map((svc) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: FilterChip( + label: SizedBox( + width: double.infinity, + child: Text( + ItServiceType.label(svc), + textAlign: TextAlign.center, + ), + ), + selected: _selectedServices.contains(svc), + onSelected: canEdit + ? (v) { + setState(() { + if (v) { + _selectedServices.add(svc); + } else { + _selectedServices.remove(svc); + } + }); + _saveField( + 'services', + _selectedServices.toList(), + ); + } + : null, + ), + ); + }).toList(), + ); + } + // Tablet/Desktop: wrap layout + return Wrap( + spacing: 8, + runSpacing: 4, + children: ItServiceType.all.map((svc) { + return FilterChip( + label: Text(ItServiceType.label(svc)), + selected: _selectedServices.contains(svc), + onSelected: canEdit + ? (v) { + setState(() { + if (v) { + _selectedServices.add(svc); + } else { + _selectedServices.remove(svc); + } + }); + _saveField( + 'services', + _selectedServices.toList(), + ); + } + : null, + ); + }).toList(), + ); + }, + ), + if (_selectedServices.contains(ItServiceType.others)) ...[ + const SizedBox(height: 8), + TextField( + controller: _servicesOtherController, + decoration: const InputDecoration( + labelText: 'Other services', + border: OutlineInputBorder(), + ), + enabled: canEdit, + onChanged: (_) => _debounceSave( + 'services_other', + _servicesOtherController.text, + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + + // Event details section + M3Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Event/Activity Details', style: tt.titleSmall), + const SizedBox(height: 8), + TextField( + controller: _eventNameController, + decoration: const InputDecoration( + labelText: 'Event Name *', + border: OutlineInputBorder(), + ), + enabled: canEdit, + onChanged: (_) => + _debounceSave('event_name', _eventNameController.text), + ), + const SizedBox(height: 12), + // Event Details Quill editor + _buildQuillSection( + context, + label: 'Event/Activity Details', + controller: _eventDetailsController, + isProcessing: _eventDetailsProcessing, + onGemini: () => _enhanceQuillWithGemini('event_details'), + enabled: canEdit, + isWide: isWide, + ), + const SizedBox(height: 12), + // Dates + if (isWide) + Row( + children: [ + Expanded( + child: _DateTimePicker( + label: 'Event Date & Time', + value: _eventDate, + enabled: canEdit, + onChanged: (v) { + setState(() => _eventDate = v); + _saveDateTime('event_date', v); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _DateTimePicker( + label: 'Event End', + value: _eventEndDate, + enabled: canEdit, + onChanged: (v) { + setState(() => _eventEndDate = v); + _saveDateTime('event_end_date', v); + }, + ), + ), + ], + ) + else ...[ + _DateTimePicker( + label: 'Event Date & Time', + value: _eventDate, + enabled: canEdit, + onChanged: (v) { + setState(() => _eventDate = v); + _saveDateTime('event_date', v); + }, + ), + const SizedBox(height: 8), + _DateTimePicker( + label: 'Event End', + value: _eventEndDate, + enabled: canEdit, + onChanged: (v) { + setState(() => _eventEndDate = v); + _saveDateTime('event_end_date', v); + }, + ), + ], + const SizedBox(height: 12), + if (isWide) + Row( + children: [ + Expanded( + child: _DateTimePicker( + label: 'Dry Run Date & Time', + value: _dryRunDate, + enabled: canEdit, + onChanged: (v) { + setState(() => _dryRunDate = v); + _saveDateTime('dry_run_date', v); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _DateTimePicker( + label: 'Dry Run End', + value: _dryRunEndDate, + enabled: canEdit, + onChanged: (v) { + setState(() => _dryRunEndDate = v); + _saveDateTime('dry_run_end_date', v); + }, + ), + ), + ], + ) + else ...[ + _DateTimePicker( + label: 'Dry Run Date & Time', + value: _dryRunDate, + enabled: canEdit, + onChanged: (v) { + setState(() => _dryRunDate = v); + _saveDateTime('dry_run_date', v); + }, + ), + const SizedBox(height: 8), + _DateTimePicker( + label: 'Dry Run End', + value: _dryRunEndDate, + enabled: canEdit, + onChanged: (v) { + setState(() => _dryRunEndDate = v); + _saveDateTime('dry_run_end_date', v); + }, + ), + ], + const SizedBox(height: 12), + // Contact + if (isWide) + Row( + children: [ + Expanded( + child: TextField( + controller: _contactPersonController, + decoration: const InputDecoration( + labelText: 'Contact Person', + border: OutlineInputBorder(), + ), + enabled: canEdit, + onChanged: (_) => _debounceSave( + 'contact_person', + _contactPersonController.text, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _contactNumberController, + decoration: const InputDecoration( + labelText: 'Contact Number', + border: OutlineInputBorder(), + ), + enabled: canEdit, + onChanged: (_) => _debounceSave( + 'contact_number', + _contactNumberController.text, + ), + ), + ), + ], + ) + else ...[ + TextField( + controller: _contactPersonController, + decoration: const InputDecoration( + labelText: 'Contact Person', + border: OutlineInputBorder(), + ), + enabled: canEdit, + onChanged: (_) => _debounceSave( + 'contact_person', + _contactPersonController.text, + ), + ), + const SizedBox(height: 8), + TextField( + controller: _contactNumberController, + decoration: const InputDecoration( + labelText: 'Contact Number', + border: OutlineInputBorder(), + ), + enabled: canEdit, + onChanged: (_) => _debounceSave( + 'contact_number', + _contactNumberController.text, + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + + // IT Staff Assignment section + M3Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: _AssignmentSection( + requestId: widget.requestId, + assignments: assignments, + profileById: profileById, + canEdit: canEdit, + ), + ), + ), + const SizedBox(height: 16), + + // Remarks (Quill editor) + M3Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: _buildQuillSection( + context, + label: 'Remarks', + controller: _remarksController, + isProcessing: _remarksProcessing, + onGemini: () => _enhanceQuillWithGemini('remarks'), + enabled: canEdit, + isWide: isWide, + ), + ), + ), + const SizedBox(height: 16), + + // Signatories + M3Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Signatories', style: tt.titleSmall), + const SizedBox(height: 12), + TextField( + controller: _requestedByController, + decoration: const InputDecoration( + labelText: 'Requested by', + border: OutlineInputBorder(), + ), + enabled: canEdit, + onChanged: (_) => _debounceSave( + 'requested_by', + _requestedByController.text, + ), + ), + if (request?.approvedBy != null) ...[ + const SizedBox(height: 12), + InputDecorator( + decoration: const InputDecoration( + labelText: 'Approved by (IHOMP-Head)', + border: OutlineInputBorder(), + ), + child: Text(request!.approvedBy!), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + + // Office (Department) + M3Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Department', style: tt.titleSmall), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: _selectedOfficeId, + decoration: const InputDecoration( + labelText: 'Office/Department', + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('None'), + ), + ...officeById.entries.map( + (e) => DropdownMenuItem( + value: e.key, + child: Text(e.value.name), + ), + ), + ], + onChanged: canEdit + ? (v) { + setState(() => _selectedOfficeId = v); + _saveField('office_id', v); + } + : null, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Geofence toggle + M3Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: SwitchListTile( + title: const Text('Outside CRMC Premise (Geofence)'), + subtitle: const Text( + 'Allow assigned IT Staff to check in/out outside CRMC premise on dry run and event day', + ), + value: _outsidePremiseAllowed, + onChanged: canEdit + ? (v) { + setState(() => _outsidePremiseAllowed = v); + _saveField('outside_premise_allowed', v); + } + : null, + ), + ), + ), + const SizedBox(height: 16), + + // File Attachments + _FileAttachmentsSection(requestId: widget.requestId), + const SizedBox(height: 32), + ], + ), + ); + } + + // ------------------------------------------------------------------------- + // Action Taken Tab + // ------------------------------------------------------------------------- + Widget _buildActionTakenTab( + BuildContext context, + ItServiceRequest? request, + List actions, + Map profileById, + bool isWide, + ) { + final profile = ref.watch(currentProfileProvider).valueOrNull; + final assignments = + (ref.watch(itServiceRequestAssignmentsProvider).valueOrNull ?? []) + .where((a) => a.requestId == widget.requestId) + .toList(); + final isAssigned = + profile != null && assignments.any((a) => a.userId == profile.id); + final isPrivileged = + profile != null && + (profile.role == 'admin' || + profile.role == 'dispatcher' || + profile.role == 'it_staff'); + + _syncActionTakenFromServer(actions, profile?.id); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isAssigned || isPrivileged) ...[ + // My action taken + M3Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'My Action Taken', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + _buildQuillSection( + context, + label: 'Action Taken', + controller: _actionTakenController, + isProcessing: _actionProcessing, + onGemini: () => _enhanceQuillWithGemini('action_taken'), + enabled: true, + isWide: isWide, + isSaving: _isActionSaving, + isSaved: _isActionSaved, + ), + const SizedBox(height: 16), + // Evidence attachments + _EvidenceSection(requestId: widget.requestId), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + // All actions from other staff + if (actions.isNotEmpty) ...[ + Text( + 'All Action Reports', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + ...actions.map((action) { + final staffName = + profileById[action.userId]?.fullName ?? 'Unknown'; + return M3Card.filled( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.person, + size: 16, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + staffName, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const Spacer(), + Text( + AppTime.formatDate(action.updatedAt), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 8), + if (action.actionTaken != null) + _QuillReadOnly(deltaJson: action.actionTaken!), + ], + ), + ), + ); + }), + ], + ], + ), + ); + } + + // ------------------------------------------------------------------------- + // Activity Tab + // ------------------------------------------------------------------------- + Widget _buildActivityTab( + List logs, + Map profileById, + ) { + if (logs.isEmpty) { + return const Center(child: Text('No activity yet.')); + } + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: logs.length, + itemBuilder: (context, index) { + final log = logs[index]; + final actorName = profileById[log.actorId]?.fullName ?? 'System'; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(top: 6), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _activityLabel(log.actionType, log.meta, actorName), + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + '${AppTime.formatDate(log.createdAt)} ${AppTime.formatTime(log.createdAt)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + String _activityLabel( + String actionType, + Map? meta, + String actorName, + ) { + switch (actionType) { + case 'created': + return '$actorName created this request'; + case 'updated': + final fields = (meta?['fields'] as List?)?.join(', ') ?? ''; + return '$actorName updated $fields'; + case 'status_changed': + final status = meta?['status'] ?? ''; + return '$actorName changed status to ${ItServiceRequestStatus.label(status)}'; + case 'approved': + return '$actorName approved this request'; + case 'assigned': + return '$actorName assigned staff'; + case 'unassigned': + return '$actorName removed staff assignment'; + default: + return '$actorName: $actionType'; + } + } + + // ------------------------------------------------------------------------- + // Quill editor builder + // ------------------------------------------------------------------------- + Widget _buildQuillSection( + BuildContext context, { + required String label, + required quill.QuillController? controller, + required bool isProcessing, + required VoidCallback onGemini, + required bool enabled, + required bool isWide, + bool isSaving = false, + bool isSaved = false, + }) { + if (controller == null) { + return const SizedBox( + height: 100, + child: Center(child: Text('Loading...')), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(label), + const SizedBox(width: 8), + AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + child: isSaving + ? ScaleTransition( + key: ValueKey('${label}_saving'), + scale: _savePulse, + child: const Icon(Icons.save, size: 14), + ) + : isSaved + ? const Icon( + Icons.check_circle, + key: ValueKey('label_saved'), + color: Colors.green, + size: 14, + ) + : const SizedBox(key: ValueKey('label_idle')), + ), + const Spacer(), + if (enabled) + IconButton( + tooltip: 'Improve with Gemini', + icon: Image.asset( + 'assets/gemini_icon.png', + width: 24, + height: 24, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.auto_awesome), + ), + onPressed: onGemini, + ), + ], + ), + const SizedBox(height: 6), + GeminiAnimatedBorder( + isProcessing: isProcessing, + child: Container( + height: isWide ? 220 : 180, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).colorScheme.outline), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + if (enabled) + SizedBox( + height: 40, + child: quill.QuillSimpleToolbar( + controller: controller, + config: const quill.QuillSimpleToolbarConfig( + multiRowsDisplay: false, + showAlignmentButtons: false, + showBackgroundColorButton: false, + showCenterAlignment: false, + showColorButton: false, + showDividers: false, + showFontFamily: false, + showFontSize: false, + showHeaderStyle: false, + showIndent: false, + showInlineCode: false, + showLeftAlignment: false, + showLink: false, + showQuote: false, + showRightAlignment: false, + showSearchButton: false, + showCodeBlock: false, + showDirection: false, + showJustifyAlignment: false, + showListCheck: false, + showSubscript: false, + showSuperscript: false, + showStrikeThrough: false, + showSmallButton: false, + showClearFormat: false, + showRedo: false, + showUndo: false, + ), + ), + ), + Expanded( + child: quill.QuillEditor.basic( + controller: controller, + config: quill.QuillEditorConfig( + placeholder: 'Enter $label...', + expands: true, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + // ------------------------------------------------------------------------- + // Helpers: save, debounce, gemini + // ------------------------------------------------------------------------- + + Timer? _fieldDebounce; + + void _debounceSave(String field, String value) { + _fieldDebounce?.cancel(); + _fieldDebounce = Timer(const Duration(milliseconds: 800), () { + _saveField(field, value); + }); + } + + Future _saveField(String field, dynamic value) async { + _startSave(); + try { + await ref + .read(supabaseClientProvider) + .from('it_service_requests') + .update({field: value}) + .eq('id', widget.requestId); + _finishSave(success: true); + } catch (e) { + debugPrint('Save field error: $e'); + _finishSave(success: false); + } + } + + Future _saveDateTime(String field, DateTime? value) async { + _startSave(); + try { + await ref + .read(supabaseClientProvider) + .from('it_service_requests') + .update({field: value?.toIso8601String()}) + .eq('id', widget.requestId); + _finishSave(success: true); + } catch (e) { + debugPrint('Save datetime error: $e'); + _finishSave(success: false); + } + } + + void _debounceQuillSave( + String field, + quill.QuillController? controller, + String lastPlain, + void Function(String) setLastPlain, + Timer? debounce, + void Function(Timer) setDebounce, + ) { + if (controller == null) return; + final plain = controller.document.toPlainText().trim(); + if (plain == lastPlain) return; + setLastPlain(plain); + debounce?.cancel(); + setDebounce( + Timer(const Duration(milliseconds: 800), () async { + final delta = controller.document.toDelta(); + final deltaJson = jsonEncode(delta.toJson()); + await _saveField(field, deltaJson); + }), + ); + } + + void _saveActionTaken() { + if (_actionTakenController == null) return; + final plain = _actionTakenController!.document.toPlainText().trim(); + if (plain == _actionLastPlain) return; + _actionLastPlain = plain; + _actionDebounce?.cancel(); + _actionDebounce = Timer(const Duration(milliseconds: 800), () async { + final delta = _actionTakenController!.document.toDelta(); + final deltaJson = jsonEncode(delta.toJson()); + if (mounted) { + setState(() { + _isActionSaving = true; + _isActionSaved = false; + }); + _updateSaveAnim(); + } + try { + await ref + .read(itServiceRequestControllerProvider) + .createOrUpdateAction( + requestId: widget.requestId, + actionTaken: deltaJson, + ); + if (mounted) { + setState(() => _isActionSaved = plain.isNotEmpty); + } + } catch (e) { + debugPrint('Save action taken error: $e'); + } finally { + if (mounted) { + setState(() => _isActionSaving = false); + } + _updateSaveAnim(); + if (_isActionSaved) { + _savedBadgeTimer?.cancel(); + _savedBadgeTimer = Timer(const Duration(seconds: 2), () { + if (mounted) setState(() => _isActionSaved = false); + }); + } + } + }); + } + + Future _enhanceQuillWithGemini(String field) async { + quill.QuillController? controller; + void Function(bool) setProcessing; + + switch (field) { + case 'event_details': + controller = _eventDetailsController; + setProcessing = (v) => setState(() => _eventDetailsProcessing = v); + break; + case 'remarks': + controller = _remarksController; + setProcessing = (v) => setState(() => _remarksProcessing = v); + break; + case 'action_taken': + controller = _actionTakenController; + setProcessing = (v) => setState(() => _actionProcessing = v); + break; + default: + return; + } + + if (controller == null) return; + final plainText = controller.document.toPlainText().trim(); + if (plainText.isEmpty) { + showWarningSnackBar(context, 'Please enter some text first'); + return; + } + + setProcessing(true); + try { + final aiService = AiService(); + final prompt = + 'This is an IT Service Request $field. Fix spelling and grammar, ' + 'improve clarity, and translate to professional English. ' + 'Return ONLY the improved text, no explanations:'; + final improved = await aiService.enhanceText( + plainText, + promptInstruction: prompt, + ); + final trimmed = improved.trim(); + + final deltaJson = jsonEncode([ + {'insert': '$trimmed\n'}, + ]); + + if (field == 'action_taken') { + await ref + .read(itServiceRequestControllerProvider) + .createOrUpdateAction( + requestId: widget.requestId, + actionTaken: deltaJson, + ); + } else { + await _saveField(field, deltaJson); + } + + final docLen = controller.document.length; + controller.replaceText( + 0, + docLen - 1, + trimmed, + TextSelection.collapsed(offset: trimmed.length), + ); + + if (mounted) showSuccessSnackBar(context, 'Text improved successfully'); + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Error: $e'); + } finally { + setProcessing(false); + } + } + + Future _approveRequest( + BuildContext context, + ItServiceRequest request, + ) async { + final profile = ref.read(currentProfileProvider).valueOrNull; + if (profile == null) return; + + try { + final ctrl = ref.read(itServiceRequestControllerProvider); + await ctrl.approveRequest( + requestId: request.id, + approverName: profile.fullName, + ); + + // Notify assigned staff + final assignments = + (ref.read(itServiceRequestAssignmentsProvider).valueOrNull ?? []) + .where((a) => a.requestId == request.id) + .toList(); + if (assignments.isNotEmpty) { + final notifCtrl = ref.read(notificationsControllerProvider); + await notifCtrl.createNotification( + userIds: assignments.map((a) => a.userId).toList(), + type: 'isr_approved', + actorId: profile.id, + fields: {'it_service_request_id': request.id}, + pushTitle: 'IT Service Request Approved', + pushBody: '${request.eventName} has been approved and scheduled', + pushData: {'it_service_request_id': request.id}, + ); + } + + if (mounted) showSuccessSnackBar(context, 'Request approved'); + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Error: $e'); + } + } + + Future _changeStatus( + BuildContext context, + ItServiceRequest request, + String newStatus, + ) async { + // Validate action taken before completing + if (newStatus == 'completed') { + final actions = + ref + .read(itServiceRequestActionsProvider(widget.requestId)) + .valueOrNull ?? + []; + final profile = ref.read(currentProfileProvider).valueOrNull; + final myAction = actions + .where((a) => a.userId == profile?.id) + .firstOrNull; + if (myAction == null || + myAction.actionTaken == null || + myAction.actionTaken!.trim().isEmpty) { + showWarningSnackBar( + context, + 'Please fill in the Action Taken tab before completing', + ); + _tabController.animateTo(1); + return; + } + } + + String? cancellationReason; + if (newStatus == 'cancelled') { + cancellationReason = await showDialog( + context: context, + builder: (ctx) { + final controller = TextEditingController(); + return AlertDialog( + title: const Text('Cancellation Reason'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Reason', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Back'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, controller.text), + child: const Text('Cancel Request'), + ), + ], + ); + }, + ); + if (cancellationReason == null) return; + } + + try { + final ctrl = ref.read(itServiceRequestControllerProvider); + await ctrl.updateStatus( + requestId: request.id, + status: newStatus, + cancellationReason: cancellationReason, + ); + + // Notify assigned staff of status change + final assignments = + (ref.read(itServiceRequestAssignmentsProvider).valueOrNull ?? []) + .where((a) => a.requestId == request.id) + .toList(); + final profile = ref.read(currentProfileProvider).valueOrNull; + if (assignments.isNotEmpty && profile != null) { + final notifCtrl = ref.read(notificationsControllerProvider); + await notifCtrl.createNotification( + userIds: assignments + .map((a) => a.userId) + .where((uid) => uid != profile.id) + .toList(), + type: 'isr_status_changed', + actorId: profile.id, + fields: {'it_service_request_id': request.id}, + pushTitle: 'IT Service Request Updated', + pushBody: + '${request.eventName} status changed to ${ItServiceRequestStatus.label(newStatus)}', + pushData: {'it_service_request_id': request.id}, + ); + } + + if (mounted) { + showSuccessSnackBar( + context, + 'Status changed to ${ItServiceRequestStatus.label(newStatus)}', + ); + } + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Error: $e'); + } + } + + List> _statusOptions(ItServiceRequest request) { + final current = request.status; + final options = []; + switch (current) { + case 'draft': + options.addAll(['pending_approval', 'cancelled']); + break; + case 'pending_approval': + options.addAll(['cancelled']); + break; + case 'scheduled': + options.addAll(['in_progress_dry_run', 'in_progress', 'cancelled']); + break; + case 'in_progress_dry_run': + options.addAll(['in_progress', 'cancelled']); + break; + case 'in_progress': + options.addAll(['completed', 'cancelled']); + break; + } + return options + .map( + (s) => PopupMenuItem( + value: s, + child: Text(ItServiceRequestStatus.label(s)), + ), + ) + .toList(); + } +} + +// --------------------------------------------------------------------------- +// Assignment Section +// --------------------------------------------------------------------------- + +class _AssignmentSection extends ConsumerWidget { + const _AssignmentSection({ + required this.requestId, + required this.assignments, + required this.profileById, + required this.canEdit, + }); + + final String requestId; + final List assignments; + final Map profileById; + final bool canEdit; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + final itStaff = + profileById.values.where((p) => p.role == 'it_staff').toList() + ..sort((a, b) => a.fullName.compareTo(b.fullName)); + + final assignedIds = assignments.map((a) => a.userId).toSet(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('IT Staff Assigned', style: tt.titleSmall), + const Spacer(), + if (canEdit) + IconButton( + icon: const Icon(Icons.person_add), + tooltip: 'Assign IT Staff', + onPressed: () async { + final selected = await showDialog>( + context: context, + builder: (ctx) => _StaffPickerDialog( + itStaff: itStaff, + alreadyAssigned: assignedIds, + ), + ); + if (selected != null && selected.isNotEmpty) { + final ctrl = ref.read(itServiceRequestControllerProvider); + await ctrl.assignStaff( + requestId: requestId, + userIds: selected, + ); + + // Notify newly assigned staff + final profile = ref + .read(currentProfileProvider) + .valueOrNull; + if (profile != null) { + final request = ref.read( + itServiceRequestByIdProvider(requestId), + ); + final notifCtrl = ref.read( + notificationsControllerProvider, + ); + await notifCtrl.createNotification( + userIds: selected, + type: 'isr_assigned', + actorId: profile.id, + fields: {'it_service_request_id': requestId}, + pushTitle: 'IT Service Request Assignment', + pushBody: + 'You have been assigned to ${request?.eventName ?? 'an IT Service Request'}', + pushData: {'it_service_request_id': requestId}, + ); + } + } + }, + ), + ], + ), + const SizedBox(height: 8), + if (assignments.isEmpty) + Text( + 'No staff assigned yet', + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ) + else + Wrap( + spacing: 8, + runSpacing: 4, + children: assignments.map((a) { + final name = profileById[a.userId]?.fullName ?? 'Unknown'; + return Chip( + avatar: CircleAvatar( + radius: 12, + backgroundColor: cs.primaryContainer, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: tt.labelSmall?.copyWith( + color: cs.onPrimaryContainer, + ), + ), + ), + label: Text(name), + onDeleted: canEdit + ? () async { + final ctrl = ref.read( + itServiceRequestControllerProvider, + ); + await ctrl.unassignStaff( + requestId: requestId, + userId: a.userId, + ); + } + : null, + ); + }).toList(), + ), + ], + ); + } +} + +class _StaffPickerDialog extends StatefulWidget { + const _StaffPickerDialog({ + required this.itStaff, + required this.alreadyAssigned, + }); + + final List itStaff; + final Set alreadyAssigned; + + @override + State<_StaffPickerDialog> createState() => _StaffPickerDialogState(); +} + +class _StaffPickerDialogState extends State<_StaffPickerDialog> { + final _selected = {}; + + @override + Widget build(BuildContext context) { + final available = widget.itStaff + .where((p) => !widget.alreadyAssigned.contains(p.id)) + .toList(); + return AlertDialog( + title: const Text('Select IT Staff'), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + content: SizedBox( + width: 350, + height: 400, + child: available.isEmpty + ? const Center(child: Text('All IT staff are already assigned')) + : ListView( + children: available.map((p) { + return CheckboxListTile( + title: Text(p.fullName), + value: _selected.contains(p.id), + onChanged: (v) { + setState(() { + if (v == true) { + _selected.add(p.id); + } else { + _selected.remove(p.id); + } + }); + }, + ); + }).toList(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, _selected.toList()), + child: const Text('Assign'), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Date Time Picker +// --------------------------------------------------------------------------- + +class _DateTimePicker extends StatelessWidget { + const _DateTimePicker({ + required this.label, + required this.value, + required this.enabled, + required this.onChanged, + }); + + final String label; + final DateTime? value; + final bool enabled; + final void Function(DateTime?) onChanged; + + @override + Widget build(BuildContext context) { + final display = value != null + ? '${AppTime.formatDate(value!)} ${AppTime.formatTime(value!)}' + : 'Not set'; + return InkWell( + onTap: enabled ? () => _pick(context) : null, + borderRadius: BorderRadius.circular(12), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + suffixIcon: const Icon(Icons.calendar_today), + ), + child: Text(display), + ), + ); + } + + Future _pick(BuildContext context) async { + final date = await showDatePicker( + context: context, + initialDate: value ?? DateTime.now(), + firstDate: DateTime(2024), + lastDate: DateTime(2030), + ); + if (date == null || !context.mounted) return; + final time = await showTimePicker( + context: context, + initialTime: value != null + ? TimeOfDay.fromDateTime(value!) + : TimeOfDay.now(), + ); + if (time == null || !context.mounted) return; + // Create DateTime in app timezone (Asia/Manila) + final pickedDateTime = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + final appDateTime = AppTime.toAppTime(pickedDateTime); + onChanged(appDateTime); + } +} + +// --------------------------------------------------------------------------- +// Quill read-only viewer +// --------------------------------------------------------------------------- + +class _QuillReadOnly extends StatelessWidget { + const _QuillReadOnly({required this.deltaJson}); + final String deltaJson; + + @override + Widget build(BuildContext context) { + try { + final decoded = jsonDecode(deltaJson); + if (decoded is List) { + final doc = quill.Document.fromJson(decoded); + final controller = quill.QuillController( + document: doc, + selection: const TextSelection.collapsed(offset: 0), + ); + return quill.QuillEditor.basic( + controller: controller, + config: const quill.QuillEditorConfig( + showCursor: false, + autoFocus: false, + ), + ); + } + } catch (_) {} + // Fallback to plain text + return Text(deltaJson); + } +} + +// --------------------------------------------------------------------------- +// File Attachments Section +// --------------------------------------------------------------------------- + +class _FileAttachmentsSection extends ConsumerStatefulWidget { + const _FileAttachmentsSection({required this.requestId}); + final String requestId; + + @override + ConsumerState<_FileAttachmentsSection> createState() => + _FileAttachmentsSectionState(); +} + +class _FileAttachmentsSectionState + extends ConsumerState<_FileAttachmentsSection> { + List> _attachments = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadAttachments(); + } + + Future _loadAttachments() async { + final ctrl = ref.read(itServiceRequestControllerProvider); + final files = await ctrl.listAttachments(widget.requestId); + if (mounted) { + setState(() { + _attachments = files; + _loading = false; + }); + } + } + + Future _upload() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + withData: true, + allowMultiple: false, + ); + if (result == null || result.files.isEmpty) return; + final file = result.files.first; + if (file.bytes == null) return; + if (file.bytes!.length > 25 * 1024 * 1024) { + if (mounted) showWarningSnackBar(context, 'File exceeds 25MB limit'); + return; + } + + try { + final ctrl = ref.read(itServiceRequestControllerProvider); + await ctrl.uploadAttachment( + requestId: widget.requestId, + fileName: file.name, + bytes: file.bytes!, + ); + await _loadAttachments(); + if (mounted) showSuccessSnackBar(context, 'File uploaded'); + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Upload error: $e'); + } + } + + Future _delete(String fileName) async { + try { + final ctrl = ref.read(itServiceRequestControllerProvider); + await ctrl.deleteAttachment( + requestId: widget.requestId, + fileName: fileName, + ); + await _loadAttachments(); + if (mounted) showSuccessSnackBar(context, 'File deleted'); + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Delete error: $e'); + } + } + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + return M3Card.outlined( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('File Attachments', style: tt.titleSmall), + const Spacer(), + IconButton( + icon: const Icon(Icons.attach_file), + tooltip: 'Upload file (max 25MB)', + onPressed: _upload, + ), + ], + ), + const SizedBox(height: 8), + if (_loading) + const Center(child: CircularProgressIndicator()) + else if (_attachments.isEmpty) + Text( + 'No attachments', + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ) + else + ...List.generate(_attachments.length, (i) { + final file = _attachments[i]; + return ListTile( + leading: const Icon(Icons.insert_drive_file), + title: Text(file['name'] as String, style: tt.bodyMedium), + dense: true, + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _delete(file['name'] as String), + ), + ); + }), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Evidence Section +// --------------------------------------------------------------------------- + +class _EvidenceSection extends ConsumerStatefulWidget { + const _EvidenceSection({required this.requestId}); + final String requestId; + + @override + ConsumerState<_EvidenceSection> createState() => _EvidenceSectionState(); +} + +class _EvidenceSectionState extends ConsumerState<_EvidenceSection> { + List> _evidence = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadEvidence(); + } + + Future _loadEvidence() async { + final ctrl = ref.read(itServiceRequestControllerProvider); + final items = await ctrl.listEvidence(widget.requestId); + if (mounted) { + setState(() { + _evidence = items; + _loading = false; + }); + } + } + + Future _uploadEvidence() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + withData: true, + allowMultiple: false, + ); + if (result == null || result.files.isEmpty) return; + final file = result.files.first; + if (file.bytes == null) return; + + // Validate image metadata date if possible + DateTime? takenAt; + // Simple EXIF date validation — on mobile we can check modification date + // The metadata is best-effort for now + try { + // Create action first (if not exists) + final ctrl = ref.read(itServiceRequestControllerProvider); + final actionId = await ctrl.createOrUpdateAction( + requestId: widget.requestId, + actionTaken: '', // Preserve existing if any + ); + + await ctrl.uploadEvidence( + requestId: widget.requestId, + actionId: actionId, + fileName: file.name, + bytes: file.bytes!, + takenAt: takenAt, + ); + await _loadEvidence(); + if (mounted) showSuccessSnackBar(context, 'Evidence uploaded'); + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Upload error: $e'); + } + } + + Future _deleteEvidence(String evidenceId) async { + try { + final ctrl = ref.read(itServiceRequestControllerProvider); + await ctrl.deleteEvidence(evidenceId: evidenceId); + await _loadEvidence(); + if (mounted) showSuccessSnackBar(context, 'Evidence deleted'); + } catch (e) { + if (mounted) showErrorSnackBar(context, 'Delete error: $e'); + } + } + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Evidence Attachments (images only)', style: tt.titleSmall), + const Spacer(), + IconButton( + icon: const Icon(Icons.add_a_photo), + tooltip: 'Upload evidence image', + onPressed: _uploadEvidence, + ), + ], + ), + const SizedBox(height: 8), + if (_loading) + const Center(child: CircularProgressIndicator()) + else if (_evidence.isEmpty) + Text( + 'No evidence uploaded', + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ) + else + Wrap( + spacing: 12, + runSpacing: 12, + children: _evidence.map((e) { + return Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + e['url'] as String, + width: 120, + height: 120, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + width: 120, + height: 120, + color: cs.surfaceContainerHighest, + child: const Icon(Icons.broken_image), + ), + ), + ), + Positioned( + top: 2, + right: 2, + child: IconButton( + icon: Icon(Icons.close, color: cs.error, size: 18), + onPressed: () => _deleteEvidence(e['id'] as String), + style: IconButton.styleFrom( + backgroundColor: cs.surface.withValues(alpha: 0.8), + minimumSize: const Size(28, 28), + padding: EdgeInsets.zero, + ), + ), + ), + ], + ); + }).toList(), + ), + ], + ); + } +} diff --git a/lib/screens/it_service_requests/it_service_request_pdf.dart b/lib/screens/it_service_requests/it_service_request_pdf.dart new file mode 100644 index 00000000..bb595216 --- /dev/null +++ b/lib/screens/it_service_requests/it_service_request_pdf.dart @@ -0,0 +1,548 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:pdf/widgets.dart' as pw; +import 'package:pdf/pdf.dart' as pdf; +import 'package:printing/printing.dart'; + +import '../../models/it_service_request.dart'; +import '../../models/it_service_request_assignment.dart'; +import '../../models/office.dart'; +import '../../models/profile.dart'; +import '../../utils/app_time.dart'; + +/// Build PDF bytes for IT Service Request Form. +Future buildItServiceRequestPdfBytes({ + required ItServiceRequest request, + required List assignments, + required Map profileById, + required Map officeById, + pdf.PdfPageFormat? format, +}) async { + final logoData = await rootBundle.load('assets/crmc_logo.png'); + final logoImage = pw.MemoryImage(logoData.buffer.asUint8List()); + + final regularFontData = await rootBundle.load( + 'assets/fonts/Roboto-Regular.ttf', + ); + final boldFontData = await rootBundle.load('assets/fonts/Roboto-Bold.ttf'); + final regularFont = pw.Font.ttf(regularFontData); + final boldFont = pw.Font.ttf(boldFontData); + + final doc = pw.Document(); + + final officeName = request.officeId != null + ? officeById[request.officeId]?.name ?? '' + : ''; + final assignedStaff = assignments + .map((a) => profileById[a.userId]?.fullName ?? a.userId) + .toList(); + final eventDetailsText = _plainFromDelta(request.eventDetails); + final remarksText = _plainFromDelta(request.remarks); + final selectedServices = request.services; + final othersText = request.servicesOther ?? ''; + final eventNameWithDetails = eventDetailsText.isEmpty + ? request.eventName + : '${request.eventName}: $eventDetailsText'; + + final dateTimeReceivedStr = request.dateTimeReceived != null + ? '${AppTime.formatDate(request.dateTimeReceived!)} ${AppTime.formatTime(request.dateTimeReceived!)}' + : ''; + final dateTimeCheckedStr = request.dateTimeChecked != null + ? '${AppTime.formatDate(request.dateTimeChecked!)} ${AppTime.formatTime(request.dateTimeChecked!)}' + : ''; + + final eventDateStr = request.eventDate != null + ? '${AppTime.formatDate(request.eventDate!)} ${AppTime.formatTime(request.eventDate!)}' + : ''; + final eventEndStr = request.eventEndDate != null + ? ' to ${AppTime.formatDate(request.eventEndDate!)} ${AppTime.formatTime(request.eventEndDate!)}' + : ''; + final dryRunDateStr = request.dryRunDate != null + ? '${AppTime.formatDate(request.dryRunDate!)} ${AppTime.formatTime(request.dryRunDate!)}' + : ''; + final dryRunEndStr = request.dryRunEndDate != null + ? ' to ${AppTime.formatDate(request.dryRunEndDate!)} ${AppTime.formatTime(request.dryRunEndDate!)}' + : ''; + + final smallStyle = pw.TextStyle(fontSize: 8); + final labelStyle = pw.TextStyle(fontSize: 10); + final boldLabelStyle = pw.TextStyle( + fontSize: 10, + fontWeight: pw.FontWeight.bold, + ); + final headerItalicStyle = pw.TextStyle( + fontSize: 10, + fontStyle: pw.FontStyle.italic, + ); + final headerBoldStyle = pw.TextStyle( + fontSize: 11, + fontWeight: pw.FontWeight.bold, + ); + + doc.addPage( + pw.MultiPage( + pageFormat: format ?? pdf.PdfPageFormat.a4, + margin: const pw.EdgeInsets.symmetric(horizontal: 40, vertical: 28), + theme: pw.ThemeData.withFont( + base: regularFont, + bold: boldFont, + italic: regularFont, + boldItalic: boldFont, + ), + footer: (pw.Context ctx) => pw.Container( + alignment: pw.Alignment.centerRight, + child: pw.Text('MC-IHO-F-17 Rev. 0', style: pw.TextStyle(fontSize: 8)), + ), + build: (pw.Context ctx) => [ + // ── Header ── + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.center, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Container(width: 64, height: 64, child: pw.Image(logoImage)), + pw.SizedBox(width: 12), + pw.Column( + mainAxisSize: pw.MainAxisSize.min, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text( + 'Republic of the Philippines', + style: headerItalicStyle, + ), + pw.Text('Department of Health', style: headerItalicStyle), + pw.Text( + 'COTABATO REGIONAL AND MEDICAL CENTER', + style: headerBoldStyle, + ), + pw.SizedBox(height: 6), + pw.Text( + 'INTEGRATED HOSPITAL OPERATIONS AND MANAGEMENT PROGRAM', + style: pw.TextStyle( + fontSize: 9, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Text( + 'IHOMP', + style: pw.TextStyle( + fontSize: 9, + fontWeight: pw.FontWeight.bold, + ), + ), + ], + ), + ], + ), + pw.SizedBox(height: 14), + + // ── Title ── + pw.Center( + child: pw.Text( + 'IT SERVICE REQUEST FORM', + style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold), + ), + ), + pw.SizedBox(height: 14), + + // ── Note + Date/Time Received/Checked ── + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Expanded( + flex: 3, + child: pw.Text( + '* Ensure availability of venue, power supply, sound system, ' + 'microphone, power point presentation, videos, music and ' + 'other necessary files needed for the event of activity.', + style: pw.TextStyle( + fontSize: 8, + fontStyle: pw.FontStyle.italic, + ), + ), + ), + pw.SizedBox(width: 16), + pw.Expanded( + flex: 2, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + _underlineField( + 'Date/Time Received:', + dateTimeReceivedStr, + style: labelStyle, + ), + pw.SizedBox(height: 6), + _underlineField( + 'Date/Time Checked:', + dateTimeCheckedStr, + style: labelStyle, + ), + ], + ), + ), + ], + ), + pw.SizedBox(height: 14), + + // ── Services ── + pw.Text('Services', style: boldLabelStyle), + pw.SizedBox(height: 6), + // Row 1: FB Live Stream, Technical Assistance, Others + pw.Row( + children: [ + pw.Expanded( + child: _checkbox( + 'FB Live Stream', + selectedServices.contains(ItServiceType.fbLiveStream), + style: labelStyle, + ), + ), + pw.Expanded( + child: _checkbox( + 'Technical Assistance', + selectedServices.contains(ItServiceType.technicalAssistance), + style: labelStyle, + ), + ), + pw.Expanded( + child: _checkbox( + 'Others${othersText.isNotEmpty ? ' ($othersText)' : ''}', + selectedServices.contains(ItServiceType.others), + style: labelStyle, + ), + ), + ], + ), + pw.SizedBox(height: 2), + // Row 2: Video Recording, WiFi + pw.Row( + children: [ + pw.Expanded( + child: _checkbox( + 'Video Recording', + selectedServices.contains(ItServiceType.videoRecording), + style: labelStyle, + ), + ), + pw.Expanded( + child: _checkbox( + 'WiFi', + selectedServices.contains(ItServiceType.wifi), + style: labelStyle, + ), + ), + pw.Expanded(child: pw.SizedBox()), + ], + ), + pw.SizedBox(height: 14), + + // ── Event/Activity Details ── + pw.Text('Event/Activity Details', style: boldLabelStyle), + _underlineField('Event Name', eventNameWithDetails, style: labelStyle), + pw.SizedBox(height: 14), + + // ── 4-column: Event Date/Time, Dry Run, Contact Person, Contact Number ── + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Event Date and Time', style: smallStyle), + pw.SizedBox(height: 4), + _underlinedText( + '$eventDateStr$eventEndStr', + style: smallStyle, + ), + ], + ), + ), + pw.SizedBox(width: 8), + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Dry Run Date and Time', style: smallStyle), + pw.SizedBox(height: 4), + _underlinedText( + '$dryRunDateStr$dryRunEndStr', + style: smallStyle, + ), + ], + ), + ), + pw.SizedBox(width: 8), + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Contact Person', style: smallStyle), + pw.SizedBox(height: 4), + _underlinedText( + request.contactPerson ?? '', + style: smallStyle, + ), + ], + ), + ), + pw.SizedBox(width: 8), + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Contact Number', style: smallStyle), + pw.SizedBox(height: 4), + _underlinedText( + request.contactNumber ?? '', + style: smallStyle, + ), + ], + ), + ), + ], + ), + pw.SizedBox(height: 14), + + // ── IT Staff/s Assigned ── + pw.Text('IT Staff/s Assigned', style: boldLabelStyle), + pw.SizedBox(height: 4), + // Show each staff on a separate underlined row, or empty lines + if (assignedStaff.isNotEmpty) + ...assignedStaff.map( + (name) => pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 4), + child: _underlinedText(name, style: labelStyle), + ), + ) + else ...[ + _underlinedText('', style: labelStyle), + pw.SizedBox(height: 4), + _underlinedText('', style: labelStyle), + ], + pw.SizedBox(height: 14), + + // ── Remarks ── + pw.Text('Remarks:', style: boldLabelStyle), + pw.SizedBox(height: 4), + pw.Container( + width: double.infinity, + constraints: const pw.BoxConstraints(minHeight: 60), + padding: const pw.EdgeInsets.all(4), + child: pw.Text(remarksText, style: labelStyle), + ), + pw.SizedBox(height: 28), + + // ── Signature blocks ── + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // Left: Requested by + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Requested by:', style: labelStyle), + pw.SizedBox(height: 36), + pw.Container( + width: double.infinity, + decoration: const pw.BoxDecoration( + border: pw.Border(bottom: pw.BorderSide(width: 0.8)), + ), + padding: const pw.EdgeInsets.only(bottom: 2), + child: pw.Center( + child: pw.Text( + request.requestedBy ?? '', + style: boldLabelStyle, + ), + ), + ), + pw.SizedBox(height: 2), + pw.Center( + child: pw.Text( + 'Signature over printed name', + style: smallStyle, + ), + ), + pw.SizedBox(height: 10), + _underlineField('Department:', officeName, style: labelStyle), + pw.SizedBox(height: 6), + _underlineField( + 'Date:', + AppTime.formatDate(request.createdAt), + style: labelStyle, + ), + ], + ), + ), + pw.SizedBox(width: 40), + // Right: Approved by + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Approved by:', style: labelStyle), + pw.SizedBox(height: 36), + pw.Container( + width: double.infinity, + decoration: const pw.BoxDecoration( + border: pw.Border(bottom: pw.BorderSide(width: 0.8)), + ), + padding: const pw.EdgeInsets.only(bottom: 2), + child: pw.Center( + child: pw.Text( + request.approvedBy ?? '', + style: boldLabelStyle, + ), + ), + ), + pw.SizedBox(height: 2), + pw.Center( + child: pw.Text('IHOMP \u2013 Head', style: smallStyle), + ), + pw.SizedBox(height: 10), + _underlineField( + 'Date:', + request.approvedAt != null + ? AppTime.formatDate(request.approvedAt!) + : '', + style: labelStyle, + ), + ], + ), + ), + ], + ), + pw.SizedBox(height: 12), + ], + ), + ); + + return doc.save(); +} + +/// A checkbox with label, matching the form layout. +pw.Widget _checkbox(String label, bool checked, {pw.TextStyle? style}) { + return pw.Row( + mainAxisSize: pw.MainAxisSize.min, + children: [ + pw.Container( + width: 10, + height: 10, + decoration: pw.BoxDecoration(border: pw.Border.all(width: 0.8)), + child: checked + ? pw.Center( + child: pw.Text( + 'X', + style: pw.TextStyle( + fontSize: 7, + fontWeight: pw.FontWeight.bold, + ), + ), + ) + : null, + ), + pw.SizedBox(width: 4), + pw.Text(label, style: style), + ], + ); +} + +/// A label followed by an underlined value, e.g. "Date/Time Received: ____" +pw.Widget _underlineField(String label, String value, {pw.TextStyle? style}) { + return pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text(label, style: style), + pw.SizedBox(width: 4), + pw.Expanded( + child: pw.Container( + decoration: const pw.BoxDecoration( + border: pw.Border(bottom: pw.BorderSide(width: 0.8)), + ), + padding: const pw.EdgeInsets.only(bottom: 2), + child: pw.Text(value, style: style), + ), + ), + ], + ); +} + +/// Text with an underline spanning the full width. +pw.Widget _underlinedText(String value, {pw.TextStyle? style}) { + return pw.Container( + width: double.infinity, + decoration: const pw.BoxDecoration( + border: pw.Border(bottom: pw.BorderSide(width: 0.5)), + ), + padding: const pw.EdgeInsets.only(bottom: 2), + child: pw.Text(value, style: style), + ); +} + +String _plainFromDelta(String? deltaJson) { + if (deltaJson == null || deltaJson.trim().isEmpty) return ''; + dynamic decoded = deltaJson; + for (var i = 0; i < 3; i++) { + if (decoded is String) { + try { + decoded = jsonDecode(decoded); + continue; + } catch (_) { + break; + } + } + break; + } + + if (decoded is Map && decoded['ops'] is List) { + final ops = decoded['ops'] as List; + final buf = StringBuffer(); + for (final op in ops) { + if (op is Map) { + final insert = op['insert']; + if (insert is String) { + buf.write(insert); + } + } + } + return buf.toString().trim(); + } + + if (decoded is List) { + try { + final doc = quill.Document.fromJson(decoded); + return doc.toPlainText().trim(); + } catch (_) { + return decoded.join(); + } + } + return decoded.toString(); +} + +/// Generate and share/print the IT Service Request PDF. +Future generateItServiceRequestPdf({ + required BuildContext context, + required ItServiceRequest request, + required List assignments, + required Map profileById, + required Map officeById, + Uint8List? prebuiltBytes, +}) async { + final bytes = + prebuiltBytes ?? + await buildItServiceRequestPdfBytes( + request: request, + assignments: assignments, + profileById: profileById, + officeById: officeById, + ); + await Printing.layoutPdf( + onLayout: (_) async => bytes, + name: 'ISR-${request.requestNumber ?? request.id}.pdf', + ); +} diff --git a/lib/screens/it_service_requests/it_service_requests_list_screen.dart b/lib/screens/it_service_requests/it_service_requests_list_screen.dart new file mode 100644 index 00000000..2abfdc87 --- /dev/null +++ b/lib/screens/it_service_requests/it_service_requests_list_screen.dart @@ -0,0 +1,676 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +import '../../models/it_service_request.dart'; +import '../../models/it_service_request_assignment.dart'; +import '../../models/office.dart'; +import '../../models/profile.dart'; +import '../../providers/it_service_request_provider.dart'; +import '../../providers/profile_provider.dart'; +import '../../providers/realtime_controller.dart'; +import '../../providers/tickets_provider.dart'; +import '../../utils/app_time.dart'; +import '../../utils/snackbar.dart'; +import '../../widgets/m3_card.dart'; +import '../../widgets/mono_text.dart'; +import '../../widgets/reconnect_overlay.dart'; +import '../../widgets/responsive_body.dart'; +import '../../widgets/status_pill.dart'; + +class ItServiceRequestsListScreen extends ConsumerStatefulWidget { + const ItServiceRequestsListScreen({super.key}); + + @override + ConsumerState createState() => + _ItServiceRequestsListScreenState(); +} + +class _ItServiceRequestsListScreenState + extends ConsumerState + with SingleTickerProviderStateMixin { + final TextEditingController _searchController = TextEditingController(); + String? _selectedOfficeId; + String? _selectedStatus; + String? _selectedAssigneeId; + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _searchController.dispose(); + _tabController.dispose(); + super.dispose(); + } + + bool get _hasFilters { + return _searchController.text.trim().isNotEmpty || + _selectedOfficeId != null || + _selectedStatus != null || + (_tabController.index == 1 && _selectedAssigneeId != null); + } + + @override + Widget build(BuildContext context) { + final requestsAsync = ref.watch(itServiceRequestsProvider); + final profileAsync = ref.watch(currentProfileProvider); + final profilesAsync = ref.watch(profilesProvider); + final officesAsync = ref.watch(officesProvider); + final assignmentsAsync = ref.watch(itServiceRequestAssignmentsProvider); + final realtime = ref.watch(realtimeControllerProvider); + + final showSkeleton = + realtime.isChannelRecovering('it_service_requests') || + (!requestsAsync.hasValue && requestsAsync.isLoading) || + (!profileAsync.hasValue && profileAsync.isLoading); + + final canCreate = profileAsync.maybeWhen( + data: (p) => + p != null && + (p.role == 'admin' || + p.role == 'dispatcher' || + p.role == 'it_staff' || + p.role == 'standard'), + orElse: () => false, + ); + + final officeById = { + for (final o in officesAsync.valueOrNull ?? []) o.id: o, + }; + final profileById = { + for (final p in profilesAsync.valueOrNull ?? []) p.id: p, + }; + final assignments = + assignmentsAsync.valueOrNull ?? []; + + return Stack( + children: [ + ResponsiveBody( + maxWidth: double.infinity, + child: Skeletonizer( + enabled: showSkeleton, + child: Builder( + builder: (context) { + if (requestsAsync.hasError && !requestsAsync.hasValue) { + return Center( + child: Text( + 'Failed to load requests: ${requestsAsync.error}', + ), + ); + } + final allRequests = + requestsAsync.valueOrNull ?? []; + if (allRequests.isEmpty && !showSkeleton) { + return const Center( + child: Text('No IT service requests yet.'), + ); + } + final offices = officesAsync.valueOrNull ?? []; + final officesSorted = List.from(offices) + ..sort( + (a, b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); + final currentProfile = profileAsync.valueOrNull; + final userId = currentProfile?.id; + + // Tab 0: My requests (created by me or assigned to me) + // Tab 1: All requests (admin/dispatcher/it_staff) + final myAssignedIds = assignments + .where((a) => a.userId == userId) + .map((a) => a.requestId) + .toSet(); + final myRequests = allRequests + .where( + (r) => + r.creatorId == userId || myAssignedIds.contains(r.id), + ) + .toList(); + final isPrivileged = + currentProfile != null && + (currentProfile.role == 'admin' || + currentProfile.role == 'dispatcher' || + currentProfile.role == 'it_staff'); + + return Column( + children: [ + // Status summary cards + _StatusSummaryRow( + requests: allRequests, + onStatusTap: (status) { + setState(() { + _selectedStatus = _selectedStatus == status + ? null + : status; + }); + }, + selectedStatus: _selectedStatus, + ), + const SizedBox(height: 8), + // Tabs + if (isPrivileged) + TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'My Requests'), + Tab(text: 'All Requests'), + ], + ), + // Filters + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search events...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + ), + isDense: true, + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() {}); + }, + ) + : null, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 8), + DropdownButton( + value: _selectedOfficeId, + hint: const Text('Office'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All offices'), + ), + ...officesSorted.map( + (o) => DropdownMenuItem( + value: o.id, + child: Text(o.name), + ), + ), + ], + onChanged: (v) => + setState(() => _selectedOfficeId = v), + ), + if (_hasFilters) + IconButton( + icon: const Icon(Icons.filter_alt_off), + tooltip: 'Clear filters', + onPressed: () { + _searchController.clear(); + setState(() { + _selectedOfficeId = null; + _selectedStatus = null; + _selectedAssigneeId = null; + }); + }, + ), + ], + ), + ), + // List + Expanded( + child: isPrivileged + ? TabBarView( + controller: _tabController, + children: [ + _RequestList( + requests: _applyFilters(myRequests), + officeById: officeById, + profileById: profileById, + assignments: assignments, + ), + _RequestList( + requests: _applyFilters(allRequests), + officeById: officeById, + profileById: profileById, + assignments: assignments, + ), + ], + ) + : _RequestList( + requests: _applyFilters(myRequests), + officeById: officeById, + profileById: profileById, + assignments: assignments, + ), + ), + ], + ); + }, + ), + ), + ), + // FAB + if (canCreate) + Positioned( + right: 16, + bottom: 16, + child: FloatingActionButton.extended( + heroTag: 'create_isr', + onPressed: () => _showCreateDialog(context), + icon: const Icon(Icons.add), + label: const Text('New Request'), + ), + ), + const ReconnectIndicator(), + ], + ); + } + + List _applyFilters(List requests) { + var filtered = requests; + final search = _searchController.text.trim().toLowerCase(); + if (search.isNotEmpty) { + filtered = filtered + .where( + (r) => + (r.eventName.toLowerCase().contains(search)) || + (r.requestNumber?.toLowerCase().contains(search) ?? false) || + (r.contactPerson?.toLowerCase().contains(search) ?? false), + ) + .toList(); + } + if (_selectedOfficeId != null) { + filtered = filtered + .where((r) => r.officeId == _selectedOfficeId) + .toList(); + } + if (_selectedStatus != null) { + filtered = filtered.where((r) => r.status == _selectedStatus).toList(); + } + return filtered; + } + + Future _showCreateDialog(BuildContext context) async { + final nameController = TextEditingController(); + final selectedServices = {}; + final profileAsync = ref.read(currentProfileProvider); + final profile = profileAsync.valueOrNull; + + final result = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setDialogState) { + return AlertDialog( + title: const Text('New IT Service Request'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + content: SizedBox( + width: 400, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Event Name *', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + Text( + 'Services', + style: Theme.of(ctx).textTheme.titleSmall, + ), + const SizedBox(height: 8), + ...ItServiceType.all.map( + (svc) => CheckboxListTile( + title: Text(ItServiceType.label(svc)), + value: selectedServices.contains(svc), + onChanged: (v) { + setDialogState(() { + if (v == true) { + selectedServices.add(svc); + } else { + selectedServices.remove(svc); + } + }); + }, + dense: true, + controlAffinity: ListTileControlAffinity.leading, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Create'), + ), + ], + ); + }, + ); + }, + ); + + if (result != true || !context.mounted) return; + if (nameController.text.trim().isEmpty) { + showWarningSnackBar(context, 'Event name is required'); + return; + } + + try { + final ctrl = ref.read(itServiceRequestControllerProvider); + final data = await ctrl.createRequest( + eventName: nameController.text.trim(), + services: selectedServices.toList(), + requestedBy: profile?.fullName, + requestedByUserId: profile?.id, + status: (profile?.role == 'standard') ? 'pending_approval' : 'draft', + ); + if (context.mounted) { + showSuccessSnackBar(context, 'Request created'); + context.go('/it-service-requests/${data['id']}'); + } + } catch (e) { + if (context.mounted) showErrorSnackBar(context, 'Error: $e'); + } + } +} + +// --------------------------------------------------------------------------- +// Status Summary Row +// --------------------------------------------------------------------------- + +class _StatusSummaryRow extends StatelessWidget { + const _StatusSummaryRow({ + required this.requests, + required this.onStatusTap, + this.selectedStatus, + }); + + final List requests; + final void Function(String status) onStatusTap; + final String? selectedStatus; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final pending = requests + .where((r) => r.status == ItServiceRequestStatus.pendingApproval) + .length; + final scheduled = requests + .where((r) => r.status == ItServiceRequestStatus.scheduled) + .length; + final inProgress = requests + .where( + (r) => + r.status == ItServiceRequestStatus.inProgress || + r.status == ItServiceRequestStatus.inProgressDryRun, + ) + .length; + final completed = requests + .where((r) => r.status == ItServiceRequestStatus.completed) + .length; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + _SummaryChip( + label: 'Pending', + count: pending, + color: cs.tertiary, + selected: selectedStatus == ItServiceRequestStatus.pendingApproval, + onTap: () => onStatusTap(ItServiceRequestStatus.pendingApproval), + ), + const SizedBox(width: 8), + _SummaryChip( + label: 'Scheduled', + count: scheduled, + color: cs.primary, + selected: selectedStatus == ItServiceRequestStatus.scheduled, + onTap: () => onStatusTap(ItServiceRequestStatus.scheduled), + ), + const SizedBox(width: 8), + _SummaryChip( + label: 'In Progress', + count: inProgress, + color: cs.secondary, + selected: selectedStatus == ItServiceRequestStatus.inProgress, + onTap: () => onStatusTap(ItServiceRequestStatus.inProgress), + ), + const SizedBox(width: 8), + _SummaryChip( + label: 'Completed', + count: completed, + color: Colors.green, + selected: selectedStatus == ItServiceRequestStatus.completed, + onTap: () => onStatusTap(ItServiceRequestStatus.completed), + ), + ], + ), + ); + } +} + +class _SummaryChip extends StatelessWidget { + const _SummaryChip({ + required this.label, + required this.count, + required this.color, + required this.selected, + required this.onTap, + }); + + final String label; + final int count; + final Color color; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return M3Card.filled( + onTap: onTap, + color: selected + ? color.withValues(alpha: 0.2) + : Theme.of(context).colorScheme.surfaceContainerHighest, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + count.toString(), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(width: 8), + Text(label, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Request list +// --------------------------------------------------------------------------- + +class _RequestList extends StatelessWidget { + const _RequestList({ + required this.requests, + required this.officeById, + required this.profileById, + required this.assignments, + }); + + final List requests; + final Map officeById; + final Map profileById; + final List assignments; + + @override + Widget build(BuildContext context) { + if (requests.isEmpty) { + return const Center(child: Text('No requests match the current filter.')); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: requests.length, + itemBuilder: (context, index) { + final request = requests[index]; + final assignedStaff = assignments + .where((a) => a.requestId == request.id) + .map((a) => profileById[a.userId]?.fullName ?? 'Unknown') + .toList(); + final office = request.officeId != null + ? officeById[request.officeId]?.name + : null; + return _RequestTile( + request: request, + officeName: office, + assignedStaff: assignedStaff, + ); + }, + ); + } +} + +class _RequestTile extends StatelessWidget { + const _RequestTile({ + required this.request, + this.officeName, + required this.assignedStaff, + }); + + final ItServiceRequest request; + final String? officeName; + final List assignedStaff; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + return M3Card.elevated( + onTap: () => context.go('/it-service-requests/${request.id}'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (request.requestNumber != null) + MonoText(request.requestNumber!), + const Spacer(), + StatusPill(label: ItServiceRequestStatus.label(request.status)), + ], + ), + const SizedBox(height: 8), + Text( + request.eventName, + style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + if (request.services.isNotEmpty) + Wrap( + spacing: 6, + runSpacing: 4, + children: request.services + .map( + (s) => Chip( + label: Text( + ItServiceType.label(s), + style: tt.labelSmall, + ), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ) + .toList(), + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.business, size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Flexible( + child: Text( + officeName ?? 'No office', + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 16), + if (request.eventDate != null) ...[ + Icon(Icons.event, size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Text( + AppTime.formatDate(request.eventDate!), + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ], + ), + if (assignedStaff.isNotEmpty) ...[ + const SizedBox(height: 6), + Row( + children: [ + Icon(Icons.people, size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Flexible( + child: Text( + assignedStaff.join(', '), + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index eb36e1c8..ccc0060e 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -408,13 +408,14 @@ class _TaskDetailScreenState extends ConsumerState child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - task.title.isNotEmpty - ? task.title - : 'Task ${task.taskNumber ?? task.id}', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + Flexible( + child: Text( + task.title.isNotEmpty + ? task.title + : 'Task ${task.taskNumber ?? task.id}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), ), ), const SizedBox(width: 8), diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index e74f0e19..9711983e 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -442,6 +442,8 @@ class _TasksListScreenState extends ConsumerState ? task.title : (ticket?.subject ?? 'Task ${task.taskNumber ?? task.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index ef2e6cf2..3533af18 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -101,11 +101,13 @@ class _TicketDetailScreenState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - ticket.subject, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + Flexible( + child: Text( + ticket.subject, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), ), const SizedBox(width: 8), diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index 1d47fe9f..df97868a 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -274,7 +274,11 @@ class _TicketsListScreenState extends ConsumerState { leading: const Icon(Icons.confirmation_number_outlined), dense: true, visualDensity: VisualDensity.compact, - title: Text(ticket.subject), + title: Text( + ticket.subject, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 884e4f78..1af5e2c8 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -366,10 +366,10 @@ List _buildSections(String role) { selectedIcon: Icons.task, ), NavItem( - label: 'Events', - route: '/events', - icon: Icons.event_outlined, - selectedIcon: Icons.event, + label: 'IT Service Requests', + route: '/it-service-requests', + icon: Icons.miscellaneous_services_outlined, + selectedIcon: Icons.miscellaneous_services, ), NavItem( label: 'Announcement', @@ -478,10 +478,10 @@ List _standardNavItems() { selectedIcon: Icons.task, ), NavItem( - label: 'Events', - route: '/events', - icon: Icons.event_outlined, - selectedIcon: Icons.event, + label: 'IT Service Requests', + route: '/it-service-requests', + icon: Icons.miscellaneous_services_outlined, + selectedIcon: Icons.miscellaneous_services, ), ]; } diff --git a/pubspec.lock b/pubspec.lock index 67c471d4..137e5e43 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -631,7 +631,7 @@ packages: source: hosted version: "2.0.1" flutter_localizations: - dependency: transitive + dependency: "direct main" description: flutter source: sdk version: "0.0.0" @@ -785,10 +785,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "8.0.2" google_generative_ai: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 1ecce0f1..eebb585d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,12 +9,14 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter supabase_flutter: ^2.6.0 flutter_riverpod: ^2.6.1 go_router: ^14.6.2 flutter_dotenv: ^5.2.1 font_awesome_flutter: ^10.7.0 - google_fonts: ^6.2.1 + google_fonts: ^8.0.2 audioplayers: ^6.1.0 geolocator: ^13.0.1 timezone: ^0.10.1