IT Service Request

This commit is contained in:
Marc Rejohn Castillano 2026-03-08 07:54:20 +08:00
parent e4391ac465
commit 88432551c8
19 changed files with 4608 additions and 33 deletions

View File

@ -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,
],
);
}
}

View File

@ -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<String> 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<String, dynamic> map) {
List<String> 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),
);
}
}

View File

@ -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<String, dynamic> 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),
);
}
}

View File

@ -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<String, dynamic>? meta;
final DateTime createdAt;
factory ItServiceRequestActivityLog.fromMap(Map<String, dynamic> 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<String, dynamic>? meta;
final rawMeta = map['meta'];
if (rawMeta is Map<String, dynamic>) {
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<String, dynamic>) {
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,
);
}
}

View File

@ -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<String, dynamic> 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),
);
}
}

View File

@ -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),

View File

@ -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<ItServiceRequestQuery>(
(ref) => const ItServiceRequestQuery(),
);
// ---------------------------------------------------------------------------
// Stream providers
// ---------------------------------------------------------------------------
final itServiceRequestsProvider = StreamProvider<List<ItServiceRequest>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return const Stream.empty();
final client = ref.watch(supabaseClientProvider);
final profile = ref.watch(currentProfileProvider).valueOrNull;
final userOfficesAsync = ref.watch(userOfficesProvider);
final wrapper = StreamRecoveryWrapper<ItServiceRequest>(
stream: client
.from('it_service_requests')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('it_service_requests')
.select()
.order('created_at', ascending: false);
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<ItServiceRequest?, String>(
(ref, id) {
final requests = ref.watch(itServiceRequestsProvider).valueOrNull;
if (requests == null) return null;
try {
return requests.firstWhere((r) => r.id == id);
} catch (_) {
return null;
}
},
);
final itServiceRequestAssignmentsProvider =
StreamProvider<List<ItServiceRequestAssignment>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return const Stream.empty();
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ItServiceRequestAssignment>(
stream: client
.from('it_service_request_assignments')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('it_service_request_assignments')
.select()
.order('created_at', ascending: false);
return data.map(ItServiceRequestAssignment.fromMap).toList();
},
fromMap: ItServiceRequestAssignment.fromMap,
channelName: 'it_service_request_assignments',
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final itServiceRequestActivityLogsProvider =
StreamProvider.family<List<ItServiceRequestActivityLog>, String>((
ref,
requestId,
) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ItServiceRequestActivityLog>(
stream: client
.from('it_service_request_activity_logs')
.stream(primaryKey: ['id'])
.eq('request_id', requestId)
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('it_service_request_activity_logs')
.select()
.eq('request_id', requestId)
.order('created_at', ascending: false);
return data.map(ItServiceRequestActivityLog.fromMap).toList();
},
fromMap: ItServiceRequestActivityLog.fromMap,
channelName: 'isr_activity_logs_$requestId',
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final itServiceRequestActionsProvider =
StreamProvider.family<List<ItServiceRequestAction>, String>((
ref,
requestId,
) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ItServiceRequestAction>(
stream: client
.from('it_service_request_actions')
.stream(primaryKey: ['id'])
.eq('request_id', requestId)
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('it_service_request_actions')
.select()
.eq('request_id', requestId)
.order('created_at', ascending: false);
return data.map(ItServiceRequestAction.fromMap).toList();
},
fromMap: ItServiceRequestAction.fromMap,
channelName: 'isr_actions_$requestId',
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
final itServiceRequestControllerProvider = Provider<ItServiceRequestController>(
(ref) {
final client = ref.watch(supabaseClientProvider);
return ItServiceRequestController(client);
},
);
class ItServiceRequestController {
ItServiceRequestController(this._client);
final SupabaseClient _client;
/// Creates a new IT Service Request with auto-generated number.
Future<Map<String, dynamic>> createRequest({
required String eventName,
required List<String> services,
String? servicesOther,
String? officeId,
String? requestedBy,
String? requestedByUserId,
String status = 'draft',
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
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<String, dynamic>)
: result;
final requestId = row['id'] as String;
if (servicesOther != null && servicesOther.isNotEmpty) {
await _client
.from('it_service_requests')
.update({'services_other': servicesOther})
.eq('id', requestId);
}
// 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<void> updateRequest({
required String requestId,
String? eventName,
List<String>? services,
String? servicesOther,
String? eventDetails,
DateTime? eventDate,
DateTime? eventEndDate,
DateTime? dryRunDate,
DateTime? dryRunEndDate,
String? contactPerson,
String? contactNumber,
String? remarks,
String? officeId,
String? requestedBy,
String? requestedByUserId,
String? approvedBy,
String? approvedByUserId,
bool? outsidePremiseAllowed,
DateTime? dateTimeReceived,
DateTime? dateTimeChecked,
}) async {
final updates = <String, dynamic>{};
if (eventName != null) updates['event_name'] = eventName;
if (services != null) updates['services'] = services;
if (servicesOther != null) updates['services_other'] = servicesOther;
if (eventDetails != null) updates['event_details'] = eventDetails;
if (eventDate != null) updates['event_date'] = eventDate.toIso8601String();
if (eventEndDate != null) {
updates['event_end_date'] = eventEndDate.toIso8601String();
}
if (dryRunDate != null) {
updates['dry_run_date'] = dryRunDate.toIso8601String();
}
if (dryRunEndDate != null) {
updates['dry_run_end_date'] = dryRunEndDate.toIso8601String();
}
if (contactPerson != null) updates['contact_person'] = contactPerson;
if (contactNumber != null) updates['contact_number'] = contactNumber;
if (remarks != null) updates['remarks'] = remarks;
if (officeId != null) updates['office_id'] = officeId;
if (requestedBy != null) updates['requested_by'] = requestedBy;
if (requestedByUserId != null) {
updates['requested_by_user_id'] = requestedByUserId;
}
if (approvedBy != null) updates['approved_by'] = approvedBy;
if (approvedByUserId != null) {
updates['approved_by_user_id'] = approvedByUserId;
}
if (outsidePremiseAllowed != null) {
updates['outside_premise_allowed'] = outsidePremiseAllowed;
}
if (dateTimeReceived != null) {
updates['date_time_received'] = dateTimeReceived.toIso8601String();
}
if (dateTimeChecked != null) {
updates['date_time_checked'] = dateTimeChecked.toIso8601String();
}
if (updates.isEmpty) return;
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<void> updateStatus({
required String requestId,
required String status,
String? cancellationReason,
}) async {
final userId = _client.auth.currentUser?.id;
final updates = <String, dynamic>{'status': status};
if (status == 'scheduled') {
updates['approved_at'] = DateTime.now().toUtc().toIso8601String();
updates['approved_by_user_id'] = userId;
}
if (status == 'completed') {
updates['completed_at'] = DateTime.now().toUtc().toIso8601String();
}
if (status == 'cancelled') {
updates['cancelled_at'] = DateTime.now().toUtc().toIso8601String();
if (cancellationReason != null) {
updates['cancellation_reason'] = cancellationReason;
}
}
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<void> 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<void> assignStaff({
required String requestId,
required List<String> userIds,
}) async {
final actorId = _client.auth.currentUser?.id;
final rows = userIds
.map((uid) => {'request_id': requestId, 'user_id': uid})
.toList();
await _client
.from('it_service_request_assignments')
.upsert(rows, onConflict: 'request_id,user_id');
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': actorId,
'action_type': 'assigned',
'meta': {'user_ids': userIds},
});
}
Future<void> unassignStaff({
required String requestId,
required String userId,
}) async {
final actorId = _client.auth.currentUser?.id;
await _client
.from('it_service_request_assignments')
.delete()
.eq('request_id', requestId)
.eq('user_id', userId);
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': actorId,
'action_type': 'unassigned',
'meta': {'user_id': userId},
});
}
// -----------------------------------------------------------------------
// Action Taken
// -----------------------------------------------------------------------
Future<String> 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<String> uploadEvidence({
required String requestId,
required String actionId,
required String fileName,
required Uint8List bytes,
DateTime? takenAt,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
final path = '$requestId/evidence/$fileName';
if (kIsWeb) {
await _client.storage
.from('it_service_attachments')
.uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
} else {
final tmpDir = Directory.systemTemp;
final tmpFile = File('${tmpDir.path}/$fileName');
await tmpFile.writeAsBytes(bytes);
await _client.storage
.from('it_service_attachments')
.upload(path, tmpFile, fileOptions: const FileOptions(upsert: true));
try {
await tmpFile.delete();
} catch (_) {}
}
await _client.from('it_service_request_evidence').insert({
'request_id': requestId,
'action_id': actionId,
'user_id': userId,
'file_path': path,
'file_name': fileName,
'taken_at': takenAt?.toIso8601String(),
});
return _client.storage.from('it_service_attachments').getPublicUrl(path);
}
Future<void> deleteEvidence({required String evidenceId}) async {
// 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<String> uploadAttachment({
required String requestId,
required String fileName,
required Uint8List bytes,
}) async {
if (bytes.length > 25 * 1024 * 1024) {
throw Exception('File size exceeds 25MB limit');
}
final path = '$requestId/attachments/$fileName';
if (kIsWeb) {
await _client.storage
.from('it_service_attachments')
.uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
} else {
final tmpDir = Directory.systemTemp;
final tmpFile = File('${tmpDir.path}/$fileName');
await tmpFile.writeAsBytes(bytes);
await _client.storage
.from('it_service_attachments')
.upload(path, tmpFile, fileOptions: const FileOptions(upsert: true));
try {
await tmpFile.delete();
} catch (_) {}
}
return _client.storage.from('it_service_attachments').getPublicUrl(path);
}
Future<List<Map<String, dynamic>>> listAttachments(String requestId) async {
try {
final files = await _client.storage
.from('it_service_attachments')
.list(path: '$requestId/attachments');
return files
.map(
(f) => {
'name': f.name,
'url': _client.storage
.from('it_service_attachments')
.getPublicUrl('$requestId/attachments/${f.name}'),
},
)
.toList();
} catch (_) {
return [];
}
}
Future<void> deleteAttachment({
required String requestId,
required String fileName,
}) async {
final path = '$requestId/attachments/$fileName';
await _client.storage.from('it_service_attachments').remove([path]);
}
/// List evidence attachments for a request from the database.
Future<List<Map<String, dynamic>>> listEvidence(String requestId) async {
final rows = await _client
.from('it_service_request_evidence')
.select()
.eq('request_id', requestId)
.order('created_at', ascending: false);
return rows
.map(
(r) => {
'id': r['id'],
'file_name': r['file_name'],
'file_path': r['file_path'],
'taken_at': r['taken_at'],
'url': _client.storage
.from('it_service_attachments')
.getPublicUrl(r['file_path'] as String),
},
)
.toList();
}
}

View File

@ -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<GoRouter>((ref) {
@ -146,15 +148,22 @@ final appRouterProvider = Provider<GoRouter>((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',

View File

@ -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<AsyncValue<DashboardMetrics>>((ref) {
@ -89,6 +92,8 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((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<AsyncValue<DashboardMetrics>>((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<AsyncValue<DashboardMetrics>>((ref) {
);
});
int _countEventsHandledToday(
String userId,
List<dynamic> isrAssignments,
List<dynamic> 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 = <String>{};
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),
),
],
),
);

File diff suppressed because it is too large Load Diff

View File

@ -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<Uint8List> buildItServiceRequestPdfBytes({
required ItServiceRequest request,
required List<ItServiceRequestAssignment> assignments,
required Map<String, Profile> profileById,
required Map<String, Office> 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<void> generateItServiceRequestPdf({
required BuildContext context,
required ItServiceRequest request,
required List<ItServiceRequestAssignment> assignments,
required Map<String, Profile> profileById,
required Map<String, Office> 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',
);
}

View File

@ -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<ItServiceRequestsListScreen> createState() =>
_ItServiceRequestsListScreenState();
}
class _ItServiceRequestsListScreenState
extends ConsumerState<ItServiceRequestsListScreen>
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 = <String, Office>{
for (final o in officesAsync.valueOrNull ?? <Office>[]) o.id: o,
};
final profileById = <String, Profile>{
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) p.id: p,
};
final assignments =
assignmentsAsync.valueOrNull ?? <ItServiceRequestAssignment>[];
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 ?? <ItServiceRequest>[];
if (allRequests.isEmpty && !showSkeleton) {
return const Center(
child: Text('No IT service requests yet.'),
);
}
final offices = officesAsync.valueOrNull ?? <Office>[];
final officesSorted = List<Office>.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<String?>(
value: _selectedOfficeId,
hint: const Text('Office'),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('All offices'),
),
...officesSorted.map(
(o) => DropdownMenuItem<String?>(
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<ItServiceRequest> _applyFilters(List<ItServiceRequest> 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<void> _showCreateDialog(BuildContext context) async {
final nameController = TextEditingController();
final selectedServices = <String>{};
final profileAsync = ref.read(currentProfileProvider);
final profile = profileAsync.valueOrNull;
final result = await showDialog<bool>(
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<ItServiceRequest> 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<ItServiceRequest> requests;
final Map<String, Office> officeById;
final Map<String, Profile> profileById;
final List<ItServiceRequestAssignment> 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<String> 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,
),
),
],
),
],
],
),
),
);
}
}

View File

@ -408,13 +408,14 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
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),

View File

@ -442,6 +442,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
? task.title
: (ticket?.subject ??
'Task ${task.taskNumber ?? task.id}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -101,11 +101,13 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
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),

View File

@ -274,7 +274,11 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
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: [

View File

@ -366,10 +366,10 @@ List<NavSection> _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<NavItem> _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,
),
];
}

View File

@ -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:

View File

@ -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