Attendance DTR and UI Improvement for Offline Rediness
This commit is contained in:
@@ -30,6 +30,7 @@ import '../../models/it_service_request.dart';
|
|||||||
import '../../models/swap_request.dart';
|
import '../../models/swap_request.dart';
|
||||||
import '../../providers/tasks_provider.dart';
|
import '../../providers/tasks_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
|
import 'dtr_pdf.dart';
|
||||||
import 'logbook_day_activity.dart';
|
import 'logbook_day_activity.dart';
|
||||||
import 'work_log_tab.dart';
|
import 'work_log_tab.dart';
|
||||||
import '../../theme/m3_motion.dart';
|
import '../../theme/m3_motion.dart';
|
||||||
@@ -1755,6 +1756,10 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
|||||||
return 'Overtime';
|
return 'Overtime';
|
||||||
case 'on_call':
|
case 'on_call':
|
||||||
return 'On Call';
|
return 'On Call';
|
||||||
|
case 'on_call_saturday':
|
||||||
|
return 'On Call (Saturday)';
|
||||||
|
case 'on_call_sunday':
|
||||||
|
return 'On Call (Sunday)';
|
||||||
default:
|
default:
|
||||||
return shiftType;
|
return shiftType;
|
||||||
}
|
}
|
||||||
@@ -1795,6 +1800,7 @@ class _LogbookEntry {
|
|||||||
this.checkOutLng,
|
this.checkOutLng,
|
||||||
this.rawLog,
|
this.rawLog,
|
||||||
this.swapRequestId,
|
this.swapRequestId,
|
||||||
|
this.swapRemark,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
@@ -1827,6 +1833,8 @@ class _LogbookEntry {
|
|||||||
final AttendanceLog? rawLog;
|
final AttendanceLog? rawLog;
|
||||||
// Non-null when the duty schedule was created by an accepted swap.
|
// Non-null when the duty schedule was created by an accepted swap.
|
||||||
final String? swapRequestId;
|
final String? swapRequestId;
|
||||||
|
// Pre-computed remark text showing the other swap party's name.
|
||||||
|
final String? swapRemark;
|
||||||
|
|
||||||
/// Whether this entry can be re-verified (within 10 min of check-in).
|
/// Whether this entry can be re-verified (within 10 min of check-in).
|
||||||
bool canReverify(String currentUserId) {
|
bool canReverify(String currentUserId) {
|
||||||
@@ -1841,7 +1849,11 @@ class _LogbookEntry {
|
|||||||
// NOTE: logbook entry creation is handled in an async provider (so that
|
// NOTE: logbook entry creation is handled in an async provider (so that
|
||||||
// filtering/sorting does not block the UI). Use `_computeLogbookEntries`.
|
// filtering/sorting does not block the UI). Use `_computeLogbookEntries`.
|
||||||
|
|
||||||
factory _LogbookEntry.absent(DutySchedule s, Map<String, Object?> byId) {
|
factory _LogbookEntry.absent(
|
||||||
|
DutySchedule s,
|
||||||
|
Map<String, Object?> byId, {
|
||||||
|
String? swapRemark,
|
||||||
|
}) {
|
||||||
final p = byId[s.userId];
|
final p = byId[s.userId];
|
||||||
final name = p is Profile ? p.fullName : (p as String?) ?? s.userId;
|
final name = p is Profile ? p.fullName : (p as String?) ?? s.userId;
|
||||||
return _LogbookEntry(
|
return _LogbookEntry(
|
||||||
@@ -1857,6 +1869,8 @@ class _LogbookEntry {
|
|||||||
duration: '—',
|
duration: '—',
|
||||||
status: 'Absent',
|
status: 'Absent',
|
||||||
isAbsent: true,
|
isAbsent: true,
|
||||||
|
swapRequestId: s.swapRequestId,
|
||||||
|
swapRemark: swapRemark,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1876,6 +1890,10 @@ class _LogbookEntry {
|
|||||||
return 'Overtime';
|
return 'Overtime';
|
||||||
case 'on_call':
|
case 'on_call':
|
||||||
return 'On Call';
|
return 'On Call';
|
||||||
|
case 'on_call_saturday':
|
||||||
|
return 'On Call (Saturday)';
|
||||||
|
case 'on_call_sunday':
|
||||||
|
return 'On Call (Sunday)';
|
||||||
default:
|
default:
|
||||||
return shiftType;
|
return shiftType;
|
||||||
}
|
}
|
||||||
@@ -1941,6 +1959,14 @@ class _ShiftGroup {
|
|||||||
|
|
||||||
/// Whether this shift was created by an accepted swap request.
|
/// Whether this shift was created by an accepted swap request.
|
||||||
bool get isSwapped => sessions.any((s) => s.swapRequestId != null);
|
bool get isSwapped => sessions.any((s) => s.swapRequestId != null);
|
||||||
|
|
||||||
|
/// Pre-computed swap remark from any session in this group (e.g. "Swap ↔ Alice Cruz").
|
||||||
|
String? get swapRemark {
|
||||||
|
for (final s in sessions) {
|
||||||
|
if (s.swapRemark != null) return s.swapRemark;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────
|
// ────────────────────────────────────────────────
|
||||||
@@ -2011,6 +2037,12 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
|||||||
icon: const Icon(Icons.tune, size: 18),
|
icon: const Icon(Icons.tune, size: 18),
|
||||||
label: const Text('Change'),
|
label: const Text('Change'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton.filled(
|
||||||
|
tooltip: 'Export DTR as PDF',
|
||||||
|
icon: const Icon(Icons.file_download_outlined, size: 18),
|
||||||
|
onPressed: () => _exportDtr(context, ref),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2172,7 +2204,7 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
|||||||
|
|
||||||
final now = AppTime.now();
|
final now = AppTime.now();
|
||||||
final absentSchedules = schedules.where((s) {
|
final absentSchedules = schedules.where((s) {
|
||||||
if (s.shiftType == 'overtime' || s.shiftType == 'on_call') return false;
|
if (s.shiftType == 'overtime' || s.shiftType.startsWith('on_call')) return false;
|
||||||
if (logScheduleIds.contains(s.id)) return false;
|
if (logScheduleIds.contains(s.id)) return false;
|
||||||
if (!s.endTime.isBefore(now)) return false;
|
if (!s.endTime.isBefore(now)) return false;
|
||||||
if (s.startTime.isBefore(range.start) ||
|
if (s.startTime.isBefore(range.start) ||
|
||||||
@@ -2216,6 +2248,27 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
|||||||
|
|
||||||
final scheduleById = {for (final s in schedules) s.id: s};
|
final scheduleById = {for (final s in schedules) s.id: s};
|
||||||
|
|
||||||
|
// Both sides of an accepted swap share the same swapRequestId on their new
|
||||||
|
// DutySchedule rows. Group those userIds so we can resolve the other party.
|
||||||
|
final swapIdToUserIds = <String, Set<String>>{};
|
||||||
|
for (final s in schedules) {
|
||||||
|
if (s.swapRequestId != null) {
|
||||||
|
swapIdToUserIds.putIfAbsent(s.swapRequestId!, () => {}).add(s.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? swapRemarkFor(String? swapRequestId, String entryUserId) {
|
||||||
|
if (swapRequestId == null) return null;
|
||||||
|
final userIds = swapIdToUserIds[swapRequestId];
|
||||||
|
if (userIds == null) return null;
|
||||||
|
final others = userIds.where((id) => id != entryUserId).toList();
|
||||||
|
if (others.isEmpty) return null;
|
||||||
|
final names = others
|
||||||
|
.map((id) => profileById[id]?.fullName ?? id)
|
||||||
|
.join(', ');
|
||||||
|
return 'Swap ↔ $names';
|
||||||
|
}
|
||||||
|
|
||||||
final List<_LogbookEntry> entries = [
|
final List<_LogbookEntry> entries = [
|
||||||
for (final l in filtered)
|
for (final l in filtered)
|
||||||
_LogbookEntry(
|
_LogbookEntry(
|
||||||
@@ -2249,8 +2302,17 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
|||||||
checkOutLng: l.checkOutLng,
|
checkOutLng: l.checkOutLng,
|
||||||
rawLog: l,
|
rawLog: l,
|
||||||
swapRequestId: scheduleById[l.dutyScheduleId]?.swapRequestId,
|
swapRequestId: scheduleById[l.dutyScheduleId]?.swapRequestId,
|
||||||
|
swapRemark: swapRemarkFor(
|
||||||
|
scheduleById[l.dutyScheduleId]?.swapRequestId,
|
||||||
|
l.userId,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
...absentSchedules.map((s) => _LogbookEntry.absent(s, profileById)),
|
...absentSchedules.map(
|
||||||
|
(s) => _LogbookEntry.absent(
|
||||||
|
s, profileById,
|
||||||
|
swapRemark: swapRemarkFor(s.swapRequestId, s.userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
...leaveEntries,
|
...leaveEntries,
|
||||||
];
|
];
|
||||||
@@ -2468,6 +2530,112 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _exportDtr(BuildContext context, WidgetRef ref) {
|
||||||
|
final entries = _entriesAsync.valueOrNull;
|
||||||
|
if (entries == null || entries.isEmpty) {
|
||||||
|
showInfoSnackBar(context, 'No logbook data to export.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? [];
|
||||||
|
final passSlips = ref.read(passSlipsProvider).valueOrNull ?? [];
|
||||||
|
final profileList = ref.read(profilesProvider).valueOrNull ?? [];
|
||||||
|
final range = ref.read(attendanceDateRangeProvider);
|
||||||
|
final scheduleById = {for (final s in schedules) s.id: s};
|
||||||
|
final profileById = {for (final p in profileList) p.id: p};
|
||||||
|
|
||||||
|
// Ensure current user's profile is always resolvable.
|
||||||
|
final currentProfile = ref.read(currentProfileProvider).valueOrNull;
|
||||||
|
if (currentProfile != null) {
|
||||||
|
profileById.putIfAbsent(currentProfile.id, () => currentProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group entries by user, preserving insertion order.
|
||||||
|
final byUser = <String, List<_LogbookEntry>>{};
|
||||||
|
for (final e in entries) {
|
||||||
|
byUser.putIfAbsent(e.userId, () => []).add(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
final perUser = byUser.entries.map((kv) {
|
||||||
|
final userId = kv.key;
|
||||||
|
// Sort ascending for a chronological time record.
|
||||||
|
final userEntries = kv.value..sort((a, b) => a.date.compareTo(b.date));
|
||||||
|
final profile = profileById[userId];
|
||||||
|
final name = userEntries.first.name;
|
||||||
|
final role = profile?.role ?? '';
|
||||||
|
final dtrEntries = userEntries
|
||||||
|
.map((e) => _toDtrEntry(e, passSlips, scheduleById))
|
||||||
|
.toList();
|
||||||
|
return (name, role, dtrEntries);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (perUser.length == 1) {
|
||||||
|
final (name, role, dtrEntries) = perUser.first;
|
||||||
|
showDtrPdfDialog(
|
||||||
|
context,
|
||||||
|
employeeName: name,
|
||||||
|
role: role,
|
||||||
|
period: range.dateTimeRange,
|
||||||
|
entries: dtrEntries,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showDtrPdfDialogMulti(
|
||||||
|
context,
|
||||||
|
users: perUser,
|
||||||
|
period: range.dateTimeRange,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DtrEntry _toDtrEntry(
|
||||||
|
_LogbookEntry e,
|
||||||
|
List<PassSlip> allSlips,
|
||||||
|
Map<String, DutySchedule> scheduleById,
|
||||||
|
) {
|
||||||
|
final schedule =
|
||||||
|
e.dutyScheduleId != null ? scheduleById[e.dutyScheduleId!] : null;
|
||||||
|
final isLate = !e.isAbsent &&
|
||||||
|
!e.isLeave &&
|
||||||
|
schedule != null &&
|
||||||
|
e.checkInAt.isAfter(
|
||||||
|
schedule.startTime.add(const Duration(minutes: 5)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final mySlips = allSlips
|
||||||
|
.where(
|
||||||
|
(s) =>
|
||||||
|
e.dutyScheduleId != null &&
|
||||||
|
s.dutyScheduleId == e.dutyScheduleId &&
|
||||||
|
s.status == 'approved',
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(s) => DtrPassSlipEntry(
|
||||||
|
reason: s.reason,
|
||||||
|
timeRange: (s.slipStart != null && s.slipEnd != null)
|
||||||
|
? '${AppTime.formatTime(s.slipStart!)} – ${AppTime.formatTime(s.slipEnd!)}'
|
||||||
|
: '—',
|
||||||
|
status: s.status,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return DtrEntry(
|
||||||
|
date: e.date,
|
||||||
|
shiftLabel: e.shift,
|
||||||
|
checkInAt: e.checkInAt,
|
||||||
|
checkOutAt: e.checkOutAt,
|
||||||
|
checkIn: e.checkIn,
|
||||||
|
checkOut: e.checkOut,
|
||||||
|
duration: e.duration,
|
||||||
|
isAbsent: e.isAbsent,
|
||||||
|
isLeave: e.isLeave,
|
||||||
|
isLate: isLate,
|
||||||
|
leaveType: e.leaveType,
|
||||||
|
justification: e.justification,
|
||||||
|
passSlips: mySlips,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────
|
// ────────────────────────────────────────────────
|
||||||
@@ -2522,7 +2690,10 @@ class _DateGroupTile extends StatelessWidget {
|
|||||||
// If the group is an absence or leave, render as a single row.
|
// If the group is an absence or leave, render as a single row.
|
||||||
final first = group.sessions.first;
|
final first = group.sessions.first;
|
||||||
if (first.isAbsent || first.isLeave) {
|
if (first.isAbsent || first.isLeave) {
|
||||||
return _buildSessionTile(context, first, 0, 1);
|
return Card(
|
||||||
|
margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||||
|
child: _buildSessionTile(context, first, 0, 1),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
@@ -2590,11 +2761,12 @@ class _DateGroupTile extends StatelessWidget {
|
|||||||
color: colors.onSecondaryContainer),
|
color: colors.onSecondaryContainer),
|
||||||
const SizedBox(width: 3),
|
const SizedBox(width: 3),
|
||||||
Text(
|
Text(
|
||||||
'Swapped',
|
group.swapRemark ?? 'Swapped',
|
||||||
style: textTheme.labelSmall?.copyWith(
|
style: textTheme.labelSmall?.copyWith(
|
||||||
color: colors.onSecondaryContainer,
|
color: colors.onSecondaryContainer,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -2729,9 +2901,9 @@ class _DateGroupTile extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
entry.isLeave
|
entry.isLeave
|
||||||
? 'Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}'
|
? entry.name
|
||||||
: entry.isAbsent
|
: entry.isAbsent
|
||||||
? 'No Check-In Recorded'
|
? entry.name
|
||||||
: entry.justification?.isNotEmpty == true
|
: entry.justification?.isNotEmpty == true
|
||||||
? entry.justification!
|
? entry.justification!
|
||||||
: total > 1
|
: total > 1
|
||||||
@@ -2758,9 +2930,9 @@ class _DateGroupTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
entry.isLeave
|
entry.isLeave
|
||||||
? 'On Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}'
|
? '${entry.shift} — On Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}'
|
||||||
: entry.isAbsent
|
: entry.isAbsent
|
||||||
? 'Absent — no check-in recorded'
|
? '${entry.shift} — No check-in recorded'
|
||||||
: 'In: ${entry.checkIn} · Out: ${entry.checkOut} · ${entry.duration}${entry.status == 'On duty' ? ' · On duty' : ''}',
|
: 'In: ${entry.checkIn} · Out: ${entry.checkOut} · ${entry.duration}${entry.status == 'On duty' ? ' · On duty' : ''}',
|
||||||
),
|
),
|
||||||
trailing: isLeaveOrAbsent
|
trailing: isLeaveOrAbsent
|
||||||
|
|||||||
@@ -0,0 +1,604 @@
|
|||||||
|
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');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -306,7 +306,7 @@ class _CacheGridState extends State<_CacheGrid> with SingleTickerProviderStateMi
|
|||||||
crossAxisCount: 3,
|
crossAxisCount: 3,
|
||||||
mainAxisSpacing: 8,
|
mainAxisSpacing: 8,
|
||||||
crossAxisSpacing: 8,
|
crossAxisSpacing: 8,
|
||||||
childAspectRatio: 0.9,
|
mainAxisExtent: 100,
|
||||||
),
|
),
|
||||||
itemCount: _groups.length,
|
itemCount: _groups.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
@@ -376,12 +376,17 @@ class _CacheTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(icon, color: fg, size: 24),
|
Icon(icon, color: fg, size: 24),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
SizedBox(
|
||||||
label,
|
height: 30,
|
||||||
style: TextStyle(color: fg, fontSize: 11, fontWeight: FontWeight.w500),
|
child: Center(
|
||||||
textAlign: TextAlign.center,
|
child: Text(
|
||||||
maxLines: 2,
|
label,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: TextStyle(color: fg, fontSize: 11, fontWeight: FontWeight.w500),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
Reference in New Issue
Block a user