605 lines
18 KiB
Dart
605 lines
18 KiB
Dart
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');
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|