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 '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import 'dtr_pdf.dart';
|
||||
import 'logbook_day_activity.dart';
|
||||
import 'work_log_tab.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
@@ -1755,6 +1756,10 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
return 'Overtime';
|
||||
case 'on_call':
|
||||
return 'On Call';
|
||||
case 'on_call_saturday':
|
||||
return 'On Call (Saturday)';
|
||||
case 'on_call_sunday':
|
||||
return 'On Call (Sunday)';
|
||||
default:
|
||||
return shiftType;
|
||||
}
|
||||
@@ -1795,6 +1800,7 @@ class _LogbookEntry {
|
||||
this.checkOutLng,
|
||||
this.rawLog,
|
||||
this.swapRequestId,
|
||||
this.swapRemark,
|
||||
});
|
||||
|
||||
final String name;
|
||||
@@ -1827,6 +1833,8 @@ class _LogbookEntry {
|
||||
final AttendanceLog? rawLog;
|
||||
// Non-null when the duty schedule was created by an accepted swap.
|
||||
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).
|
||||
bool canReverify(String currentUserId) {
|
||||
@@ -1841,7 +1849,11 @@ class _LogbookEntry {
|
||||
// NOTE: logbook entry creation is handled in an async provider (so that
|
||||
// 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 name = p is Profile ? p.fullName : (p as String?) ?? s.userId;
|
||||
return _LogbookEntry(
|
||||
@@ -1857,6 +1869,8 @@ class _LogbookEntry {
|
||||
duration: '—',
|
||||
status: 'Absent',
|
||||
isAbsent: true,
|
||||
swapRequestId: s.swapRequestId,
|
||||
swapRemark: swapRemark,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1876,6 +1890,10 @@ class _LogbookEntry {
|
||||
return 'Overtime';
|
||||
case 'on_call':
|
||||
return 'On Call';
|
||||
case 'on_call_saturday':
|
||||
return 'On Call (Saturday)';
|
||||
case 'on_call_sunday':
|
||||
return 'On Call (Sunday)';
|
||||
default:
|
||||
return shiftType;
|
||||
}
|
||||
@@ -1941,6 +1959,14 @@ class _ShiftGroup {
|
||||
|
||||
/// Whether this shift was created by an accepted swap request.
|
||||
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),
|
||||
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 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 (!s.endTime.isBefore(now)) return false;
|
||||
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};
|
||||
|
||||
// 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 = [
|
||||
for (final l in filtered)
|
||||
_LogbookEntry(
|
||||
@@ -2249,8 +2302,17 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
||||
checkOutLng: l.checkOutLng,
|
||||
rawLog: l,
|
||||
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,
|
||||
];
|
||||
@@ -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.
|
||||
final first = group.sessions.first;
|
||||
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;
|
||||
@@ -2590,11 +2761,12 @@ class _DateGroupTile extends StatelessWidget {
|
||||
color: colors.onSecondaryContainer),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'Swapped',
|
||||
group.swapRemark ?? 'Swapped',
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colors.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -2729,9 +2901,9 @@ class _DateGroupTile extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.isLeave
|
||||
? 'Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}'
|
||||
? entry.name
|
||||
: entry.isAbsent
|
||||
? 'No Check-In Recorded'
|
||||
? entry.name
|
||||
: entry.justification?.isNotEmpty == true
|
||||
? entry.justification!
|
||||
: total > 1
|
||||
@@ -2758,9 +2930,9 @@ class _DateGroupTile extends StatelessWidget {
|
||||
),
|
||||
subtitle: Text(
|
||||
entry.isLeave
|
||||
? 'On Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}'
|
||||
? '${entry.shift} — On Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}'
|
||||
: 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' : ''}',
|
||||
),
|
||||
trailing: isLeaveOrAbsent
|
||||
|
||||
Reference in New Issue
Block a user