IT Service Request
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user