IT Service Request
This commit is contained in:
parent
e4391ac465
commit
88432551c8
|
|
@ -1,4 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'routing/app_router.dart';
|
import 'routing/app_router.dart';
|
||||||
|
|
@ -17,6 +19,12 @@ class TasqApp extends ConsumerWidget {
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
darkTheme: AppTheme.dark(),
|
darkTheme: AppTheme.dark(),
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
FlutterQuillLocalizations.delegate,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
259
lib/models/it_service_request.dart
Normal file
259
lib/models/it_service_request.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/models/it_service_request_action.dart
Normal file
42
lib/models/it_service_request_action.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
lib/models/it_service_request_activity_log.dart
Normal file
74
lib/models/it_service_request_activity_log.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
lib/models/it_service_request_assignment.dart
Normal file
24
lib/models/it_service_request_assignment.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ class NotificationItem {
|
||||||
required this.actorId,
|
required this.actorId,
|
||||||
required this.ticketId,
|
required this.ticketId,
|
||||||
required this.taskId,
|
required this.taskId,
|
||||||
|
required this.itServiceRequestId,
|
||||||
required this.messageId,
|
required this.messageId,
|
||||||
required this.type,
|
required this.type,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
|
@ -18,6 +19,7 @@ class NotificationItem {
|
||||||
final String? actorId;
|
final String? actorId;
|
||||||
final String? ticketId;
|
final String? ticketId;
|
||||||
final String? taskId;
|
final String? taskId;
|
||||||
|
final String? itServiceRequestId;
|
||||||
final int? messageId;
|
final int? messageId;
|
||||||
final String type;
|
final String type;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
|
@ -32,6 +34,7 @@ class NotificationItem {
|
||||||
actorId: map['actor_id'] as String?,
|
actorId: map['actor_id'] as String?,
|
||||||
ticketId: map['ticket_id'] as String?,
|
ticketId: map['ticket_id'] as String?,
|
||||||
taskId: map['task_id'] as String?,
|
taskId: map['task_id'] as String?,
|
||||||
|
itServiceRequestId: map['it_service_request_id'] as String?,
|
||||||
messageId: map['message_id'] as int?,
|
messageId: map['message_id'] as int?,
|
||||||
type: map['type'] as String? ?? 'mention',
|
type: map['type'] as String? ?? 'mention',
|
||||||
createdAt: AppTime.parse(map['created_at'] as String),
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
|
|
|
||||||
653
lib/providers/it_service_request_provider.dart
Normal file
653
lib/providers/it_service_request_provider.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,8 @@ import '../screens/attendance/attendance_screen.dart';
|
||||||
import '../screens/whereabouts/whereabouts_screen.dart';
|
import '../screens/whereabouts/whereabouts_screen.dart';
|
||||||
import '../widgets/app_shell.dart';
|
import '../widgets/app_shell.dart';
|
||||||
import '../screens/teams/teams_screen.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';
|
import '../theme/m3_motion.dart';
|
||||||
|
|
||||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
|
|
@ -146,16 +148,23 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/events',
|
path: '/it-service-requests',
|
||||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
key: state.pageKey,
|
key: state.pageKey,
|
||||||
child: const UnderDevelopmentScreen(
|
child: const ItServiceRequestsListScreen(),
|
||||||
title: 'Events',
|
),
|
||||||
subtitle: 'Event monitoring is under development.',
|
routes: [
|
||||||
icon: Icons.event,
|
GoRoute(
|
||||||
|
path: ':id',
|
||||||
|
pageBuilder: (context, state) => M3ContainerTransformPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: ItServiceRequestDetailScreen(
|
||||||
|
requestId: state.pathParameters['id'] ?? '',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/announcements',
|
path: '/announcements',
|
||||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import '../../providers/tasks_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
import '../../providers/whereabouts_provider.dart';
|
import '../../providers/whereabouts_provider.dart';
|
||||||
import '../../providers/workforce_provider.dart';
|
import '../../providers/workforce_provider.dart';
|
||||||
|
import '../../providers/it_service_request_provider.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../widgets/reconnect_overlay.dart';
|
import '../../widgets/reconnect_overlay.dart';
|
||||||
import '../../providers/realtime_controller.dart';
|
import '../../providers/realtime_controller.dart';
|
||||||
|
|
@ -68,6 +69,7 @@ class StaffRowMetrics {
|
||||||
required this.whereabouts,
|
required this.whereabouts,
|
||||||
required this.ticketsRespondedToday,
|
required this.ticketsRespondedToday,
|
||||||
required this.tasksClosedToday,
|
required this.tasksClosedToday,
|
||||||
|
required this.eventsHandledToday,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String userId;
|
final String userId;
|
||||||
|
|
@ -76,6 +78,7 @@ class StaffRowMetrics {
|
||||||
final String whereabouts;
|
final String whereabouts;
|
||||||
final int ticketsRespondedToday;
|
final int ticketsRespondedToday;
|
||||||
final int tasksClosedToday;
|
final int tasksClosedToday;
|
||||||
|
final int eventsHandledToday;
|
||||||
}
|
}
|
||||||
|
|
||||||
final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
|
final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
|
||||||
|
|
@ -89,6 +92,8 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
|
||||||
final positionsAsync = ref.watch(livePositionsProvider);
|
final positionsAsync = ref.watch(livePositionsProvider);
|
||||||
final leavesAsync = ref.watch(leavesProvider);
|
final leavesAsync = ref.watch(leavesProvider);
|
||||||
final passSlipsAsync = ref.watch(passSlipsProvider);
|
final passSlipsAsync = ref.watch(passSlipsProvider);
|
||||||
|
final isrAssignmentsAsync = ref.watch(itServiceRequestAssignmentsProvider);
|
||||||
|
final isrAsync = ref.watch(itServiceRequestsProvider);
|
||||||
|
|
||||||
final asyncValues = [
|
final asyncValues = [
|
||||||
ticketsAsync,
|
ticketsAsync,
|
||||||
|
|
@ -424,14 +429,22 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
|
||||||
whereabouts: whereabouts,
|
whereabouts: whereabouts,
|
||||||
ticketsRespondedToday: ticketsResponded,
|
ticketsRespondedToday: ticketsResponded,
|
||||||
tasksClosedToday: tasksClosed,
|
tasksClosedToday: tasksClosed,
|
||||||
|
eventsHandledToday: _countEventsHandledToday(
|
||||||
|
staff.id,
|
||||||
|
isrAssignmentsAsync.valueOrNull ?? [],
|
||||||
|
isrAsync.valueOrNull ?? [],
|
||||||
|
now,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Order IT staff by combined activity (tickets responded today + tasks closed today)
|
// 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.
|
// descending so most-active staff appear first. Use name as a stable tiebreaker.
|
||||||
staffRows.sort((a, b) {
|
staffRows.sort((a, b) {
|
||||||
final aCount = a.ticketsRespondedToday + a.tasksClosedToday;
|
final aCount =
|
||||||
final bCount = b.ticketsRespondedToday + b.tasksClosedToday;
|
a.ticketsRespondedToday + a.tasksClosedToday + a.eventsHandledToday;
|
||||||
|
final bCount =
|
||||||
|
b.ticketsRespondedToday + b.tasksClosedToday + b.eventsHandledToday;
|
||||||
if (bCount != aCount) return bCount.compareTo(aCount);
|
if (bCount != aCount) return bCount.compareTo(aCount);
|
||||||
return a.name.compareTo(b.name);
|
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 {
|
class DashboardScreen extends StatefulWidget {
|
||||||
const DashboardScreen({super.key});
|
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('Whereabouts', style: style)),
|
||||||
Expanded(flex: 2, child: Text('Tickets', style: style)),
|
Expanded(flex: 2, child: Text('Tickets', style: style)),
|
||||||
Expanded(flex: 2, child: Text('Tasks', 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,
|
flex: 2,
|
||||||
child: Text(row.tasksClosedToday.toString(), style: valueStyle),
|
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
548
lib/screens/it_service_requests/it_service_request_pdf.dart
Normal file
548
lib/screens/it_service_requests/it_service_request_pdf.dart
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -408,13 +408,14 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
task.title.isNotEmpty
|
task.title.isNotEmpty
|
||||||
? task.title
|
? task.title
|
||||||
: 'Task ${task.taskNumber ?? task.id}',
|
: 'Task ${task.taskNumber ?? task.id}',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(context).textTheme.titleLarge
|
||||||
fontWeight: FontWeight.w700,
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
? task.title
|
? task.title
|
||||||
: (ticket?.subject ??
|
: (ticket?.subject ??
|
||||||
'Task ${task.taskNumber ?? task.id}'),
|
'Task ${task.taskNumber ?? task.id}'),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
|
||||||
|
|
@ -101,13 +101,15 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
ticket.subject,
|
ticket.subject,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Builder(
|
Builder(
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,11 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
leading: const Icon(Icons.confirmation_number_outlined),
|
leading: const Icon(Icons.confirmation_number_outlined),
|
||||||
dense: true,
|
dense: true,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
title: Text(ticket.subject),
|
title: Text(
|
||||||
|
ticket.subject,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
|
|
@ -366,10 +366,10 @@ List<NavSection> _buildSections(String role) {
|
||||||
selectedIcon: Icons.task,
|
selectedIcon: Icons.task,
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
label: 'Events',
|
label: 'IT Service Requests',
|
||||||
route: '/events',
|
route: '/it-service-requests',
|
||||||
icon: Icons.event_outlined,
|
icon: Icons.miscellaneous_services_outlined,
|
||||||
selectedIcon: Icons.event,
|
selectedIcon: Icons.miscellaneous_services,
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
label: 'Announcement',
|
label: 'Announcement',
|
||||||
|
|
@ -478,10 +478,10 @@ List<NavItem> _standardNavItems() {
|
||||||
selectedIcon: Icons.task,
|
selectedIcon: Icons.task,
|
||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
label: 'Events',
|
label: 'IT Service Requests',
|
||||||
route: '/events',
|
route: '/it-service-requests',
|
||||||
icon: Icons.event_outlined,
|
icon: Icons.miscellaneous_services_outlined,
|
||||||
selectedIcon: Icons.event,
|
selectedIcon: Icons.miscellaneous_services,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -631,7 +631,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "2.0.1"
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
|
@ -785,10 +785,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: google_fonts
|
name: google_fonts
|
||||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.3"
|
version: "8.0.2"
|
||||||
google_generative_ai:
|
google_generative_ai:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
supabase_flutter: ^2.6.0
|
supabase_flutter: ^2.6.0
|
||||||
flutter_riverpod: ^2.6.1
|
flutter_riverpod: ^2.6.1
|
||||||
go_router: ^14.6.2
|
go_router: ^14.6.2
|
||||||
flutter_dotenv: ^5.2.1
|
flutter_dotenv: ^5.2.1
|
||||||
font_awesome_flutter: ^10.7.0
|
font_awesome_flutter: ^10.7.0
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^8.0.2
|
||||||
audioplayers: ^6.1.0
|
audioplayers: ^6.1.0
|
||||||
geolocator: ^13.0.1
|
geolocator: ^13.0.1
|
||||||
timezone: ^0.10.1
|
timezone: ^0.10.1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user