Files

605 lines
18 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:intl/intl.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as pdf;
import 'package:printing/printing.dart';
import 'package:pdfrx/pdfrx.dart';
// ─── DTOs ─────────────────────────────────────────────────────────────────
class DtrEntry {
const DtrEntry({
required this.date,
required this.shiftLabel,
required this.checkInAt,
this.checkOutAt,
required this.checkIn,
required this.checkOut,
required this.duration,
required this.isAbsent,
required this.isLeave,
required this.isLate,
this.leaveType,
this.justification,
this.passSlips = const [],
});
final DateTime date;
final String shiftLabel;
final DateTime checkInAt;
final DateTime? checkOutAt;
final String checkIn;
final String checkOut;
final String duration;
final bool isAbsent;
final bool isLeave;
final bool isLate;
final String? leaveType;
final String? justification;
final List<DtrPassSlipEntry> passSlips;
}
class DtrPassSlipEntry {
const DtrPassSlipEntry({
required this.reason,
required this.timeRange,
required this.status,
});
final String reason;
final String timeRange;
final String status;
}
// ─── Public entry points ──────────────────────────────────────────────────
void showDtrPdfDialog(
BuildContext context, {
required String employeeName,
required String role,
required DateTimeRange period,
required List<DtrEntry> entries,
}) {
showDialog(
context: context,
builder: (_) => _DtrPdfDialog(
title: 'DTR $employeeName',
filename: 'DTR - $employeeName.pdf',
buildBytes: () => _buildPdf([(employeeName, role, entries)], period),
),
);
}
void showDtrPdfDialogMulti(
BuildContext context, {
required List<(String, String, List<DtrEntry>)> users,
required DateTimeRange period,
}) {
showDialog(
context: context,
builder: (_) => _DtrPdfDialog(
title: 'DTR Export (${users.length} employees)',
filename: 'DTR Export.pdf',
buildBytes: () => _buildPdf(users, period),
),
);
}
Future<Uint8List> buildDtrPdfBytes({
required String employeeName,
required String role,
required DateTimeRange period,
required List<DtrEntry> entries,
}) =>
_buildPdf([(employeeName, role, entries)], period);
// ─── PDF builder ──────────────────────────────────────────────────────────
Future<Uint8List> _buildPdf(
List<(String, String, List<DtrEntry>)> users,
DateTimeRange period,
) async {
final logoData = await rootBundle.load('assets/crmc_logo.png');
final logoImage = pw.MemoryImage(logoData.buffer.asUint8List());
final regular = pw.Font.ttf(
await rootBundle.load('assets/fonts/Roboto-Regular.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/Roboto-Bold.ttf'),
);
final doc = pw.Document();
for (final (name, role, entries) in users) {
// Sort ascending (oldest first) for a chronological time record.
final sorted = [...entries]..sort((a, b) => a.date.compareTo(b.date));
doc.addPage(
pw.MultiPage(
pageFormat: pdf.PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
theme: pw.ThemeData.withFont(base: regular, bold: bold),
footer: (ctx) => pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Text(
'Page ${ctx.pageNumber} of ${ctx.pagesCount}',
style: pw.TextStyle(
font: regular,
fontSize: 8,
color: pdf.PdfColors.grey600,
),
),
),
build: (ctx) => [
_header(logoImage, regular, bold),
pw.SizedBox(height: 8),
pw.Center(
child: pw.Text(
'DAILY TIME RECORD (DTR)',
style: pw.TextStyle(font: bold, fontSize: 14, letterSpacing: 1.5),
),
),
pw.SizedBox(height: 10),
_employeeInfo(name, role, period, regular, bold),
pw.SizedBox(height: 10),
_attendanceTable(sorted, regular, bold),
if (sorted.any((e) => e.passSlips.isNotEmpty)) ...[
pw.SizedBox(height: 8),
_passSlipsSection(sorted, regular, bold),
],
pw.SizedBox(height: 10),
_summary(sorted, regular, bold),
],
),
);
}
return doc.save();
}
// ─── Section builders ─────────────────────────────────────────────────────
pw.Widget _header(
pw.ImageProvider logo,
pw.Font regular,
pw.Font bold,
) {
return pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Image(logo, width: 60, height: 60),
pw.SizedBox(width: 16),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Text(
'Republic of the Philippines',
style: pw.TextStyle(font: regular, fontSize: 9),
),
pw.Text(
'Department of Health',
style: pw.TextStyle(font: regular, fontSize: 9),
),
pw.Text(
'Cotabato Regional and Medical Center',
style: pw.TextStyle(font: regular, fontSize: 9),
),
pw.SizedBox(height: 3),
pw.Text(
'Integrated Hospital Operations and Management Program (IHOMP)',
style: pw.TextStyle(font: bold, fontSize: 9),
),
],
),
],
);
}
pw.Widget _employeeInfo(
String name,
String role,
DateTimeRange period,
pw.Font regular,
pw.Font bold,
) {
final fmt = DateFormat('MMMM d, yyyy');
final periodStr = '${fmt.format(period.start)} ${fmt.format(period.end)}';
pw.Widget infoRow(String label, String value) => pw.RichText(
text: pw.TextSpan(
children: [
pw.TextSpan(
text: '$label ',
style: pw.TextStyle(font: bold, fontSize: 9),
),
pw.TextSpan(
text: value,
style: pw.TextStyle(font: regular, fontSize: 9),
),
],
),
);
return pw.Container(
decoration: pw.BoxDecoration(
border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400),
),
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: pw.Row(
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
infoRow('Name:', name),
pw.SizedBox(height: 3),
infoRow('Position:', _roleLabel(role)),
],
),
),
pw.Expanded(
child: infoRow('Period:', periodStr),
),
],
),
);
}
pw.Widget _attendanceTable(
List<DtrEntry> entries,
pw.Font regular,
pw.Font bold,
) {
final dayFmt = DateFormat('EEE');
final dateFmt = DateFormat('MMM d');
pw.Widget th(String text) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: pw.Text(
text,
style: pw.TextStyle(font: bold, fontSize: 8),
),
);
pw.Widget td(String text) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3),
child: pw.Text(
text,
style: pw.TextStyle(font: regular, fontSize: 8),
),
);
String statusText(DtrEntry e) {
if (e.isAbsent) return 'Absent';
if (e.isLeave) return _leaveTypeLabel(e.leaveType);
if (e.isLate) return 'Late';
if (e.shiftLabel == 'Overtime') return 'Overtime';
if (e.duration == 'On duty') return 'Active';
return 'Present';
}
pdf.PdfColor? rowColor(DtrEntry e) {
if (e.isAbsent) return pdf.PdfColor.fromHex('#FFEBEE');
if (e.isLeave) return pdf.PdfColor.fromHex('#E3F2FD');
if (e.shiftLabel == 'Overtime') return pdf.PdfColor.fromHex('#FFF8E1');
if (e.isLate) return pdf.PdfColor.fromHex('#FFF3E0');
return null;
}
return pw.Table(
border: pw.TableBorder.all(width: 0.5, color: pdf.PdfColors.grey400),
columnWidths: const {
0: pw.FixedColumnWidth(56),
1: pw.FixedColumnWidth(28),
2: pw.FixedColumnWidth(72),
3: pw.FixedColumnWidth(60),
4: pw.FixedColumnWidth(60),
5: pw.FixedColumnWidth(50),
6: pw.FlexColumnWidth(),
},
children: [
pw.TableRow(
decoration: const pw.BoxDecoration(color: pdf.PdfColors.grey200),
children: [
th('Date'),
th('Day'),
th('Shift'),
th('Check-In'),
th('Check-Out'),
th('Hours'),
th('Remarks'),
],
),
for (final e in entries)
pw.TableRow(
decoration: pw.BoxDecoration(color: rowColor(e)),
children: [
td(dateFmt.format(e.date)),
td(dayFmt.format(e.date)),
td(e.shiftLabel),
td(e.checkIn),
td(e.checkOut),
td(e.duration),
td(statusText(e)),
],
),
],
);
}
pw.Widget _passSlipsSection(
List<DtrEntry> entries,
pw.Font regular,
pw.Font bold,
) {
final dateFmt = DateFormat('MMM d (EEE)');
final slips = <(String, DtrPassSlipEntry)>[];
for (final e in entries) {
for (final s in e.passSlips) {
slips.add((dateFmt.format(e.date), s));
}
}
pw.Widget th(String text) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3),
child: pw.Text(text, style: pw.TextStyle(font: bold, fontSize: 8)),
);
pw.Widget td(String text) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: pw.Text(text, style: pw.TextStyle(font: regular, fontSize: 7.5)),
);
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'Pass Slips',
style: pw.TextStyle(font: bold, fontSize: 9, letterSpacing: 0.5),
),
pw.SizedBox(height: 4),
pw.Table(
border: pw.TableBorder.all(width: 0.5, color: pdf.PdfColors.grey400),
columnWidths: const {
0: pw.FixedColumnWidth(88),
1: pw.FixedColumnWidth(88),
2: pw.FlexColumnWidth(),
3: pw.FixedColumnWidth(60),
},
children: [
pw.TableRow(
decoration: const pw.BoxDecoration(color: pdf.PdfColors.grey200),
children: [
th('Date'),
th('Time Range'),
th('Reason'),
th('Status'),
],
),
for (final (date, slip) in slips)
pw.TableRow(
children: [
td(date),
td(slip.timeRange),
td(slip.reason),
td(slip.status),
],
),
],
),
],
);
}
pw.Widget _summary(List<DtrEntry> entries, pw.Font regular, pw.Font bold) {
final present = entries.where((e) => !e.isAbsent && !e.isLeave).length;
final absent = entries.where((e) => e.isAbsent).length;
final lates = entries.where((e) => e.isLate).length;
final leaves = entries.where((e) => e.isLeave).toList();
final leaveByType = <String, int>{};
for (final l in leaves) {
final label = _leaveTypeLabel(l.leaveType);
leaveByType[label] = (leaveByType[label] ?? 0) + 1;
}
final leaveSummary = leaveByType.isEmpty
? '${leaves.length} day(s)'
: leaveByType.entries.map((e) => '${e.key}: ${e.value}').join(', ');
final otEntries =
entries.where((e) => e.shiftLabel == 'Overtime' && e.checkOutAt != null);
final otMinutes = otEntries.fold<int>(
0,
(sum, e) => sum + e.checkOutAt!.difference(e.checkInAt).inMinutes,
);
final otStr =
otMinutes == 0 ? 'None' : '${otMinutes ~/ 60}h ${otMinutes % 60}m';
final passSlipTotal =
entries.fold<int>(0, (s, e) => s + e.passSlips.length);
pw.Widget cell(String label, String value) => pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(label, style: pw.TextStyle(font: bold, fontSize: 8)),
pw.SizedBox(height: 2),
pw.Text(value, style: pw.TextStyle(font: regular, fontSize: 9)),
],
),
);
return pw.Container(
decoration: pw.BoxDecoration(
border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400),
color: pdf.PdfColors.grey100,
),
padding: const pw.EdgeInsets.all(8),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'SUMMARY',
style: pw.TextStyle(font: bold, fontSize: 9, letterSpacing: 1),
),
pw.SizedBox(height: 6),
pw.Row(
children: [
cell('Days Present', '$present'),
cell('Absences', '$absent'),
cell('Lates', '$lates'),
cell('Overtime', otStr),
],
),
pw.SizedBox(height: 4),
pw.Row(
children: [
cell('Leaves', leaveSummary),
cell('Pass Slips', '$passSlipTotal'),
pw.Expanded(child: pw.SizedBox()),
pw.Expanded(child: pw.SizedBox()),
],
),
],
),
);
}
// ─── Helpers ──────────────────────────────────────────────────────────────
String _roleLabel(String role) {
switch (role) {
case 'admin':
return 'Administrator';
case 'dispatcher':
return 'Dispatcher';
case 'programmer':
return 'Programmer';
case 'it_staff':
return 'IT Staff';
default:
return role.isEmpty ? 'Staff' : role;
}
}
String _leaveTypeLabel(String? leaveType) {
switch (leaveType) {
case 'sick_leave':
return 'Sick Leave';
case 'vacation_leave':
return 'Vacation Leave';
case 'emergency_leave':
return 'Emergency Leave';
case 'parental_leave':
return 'Parental Leave';
default:
return 'On Leave';
}
}
// ─── Dialog ───────────────────────────────────────────────────────────────
class _DtrPdfDialog extends StatefulWidget {
const _DtrPdfDialog({
required this.title,
required this.filename,
required this.buildBytes,
});
final String title;
final String filename;
final Future<Uint8List> Function() buildBytes;
@override
State<_DtrPdfDialog> createState() => _DtrPdfDialogState();
}
class _DtrPdfDialogState extends State<_DtrPdfDialog> {
late final Future<Uint8List> _future;
@override
void initState() {
super.initState();
_future = widget.buildBytes();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
contentPadding: EdgeInsets.zero,
content: SizedBox(
width: 800,
height: 900,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'Print',
icon: const Icon(Icons.print),
onPressed: () async {
final bytes = await _future;
await Printing.layoutPdf(
onLayout: (_) async => bytes,
name: widget.filename,
);
},
),
IconButton(
tooltip: 'Download',
icon: const Icon(Icons.download),
onPressed: () async {
final bytes = await _future;
await Printing.sharePdf(
bytes: bytes,
filename: widget.filename,
);
},
),
IconButton(
tooltip: 'Close',
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 1),
Expanded(
child: FutureBuilder<Uint8List>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text(snapshot.error.toString()));
}
final data = snapshot.data;
if (data == null) return const SizedBox.shrink();
return PdfViewer.data(data, sourceName: 'dtr.pdf');
},
),
),
],
),
),
);
}
}