From 4eaf9444f03ddd102bcc761ae8750f52f62aed81 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Wed, 18 Mar 2026 10:56:31 +0800 Subject: [PATCH] Enhanced Logbook layout --- lib/providers/attendance_provider.dart | 3 + lib/screens/attendance/attendance_screen.dart | 765 +++++++++++------- 2 files changed, 456 insertions(+), 312 deletions(-) diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index 5ba43893..f6737ced 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -22,6 +22,9 @@ final attendanceDateRangeProvider = StateProvider((ref) { ); }); +/// Filter for logbook users (multi-select). If empty, all users are shown. +final attendanceUserFilterProvider = StateProvider>((ref) => []); + /// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff). final attendanceLogsProvider = StreamProvider>((ref) { final client = ref.watch(supabaseClientProvider); diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 5b0e6f1a..654c7800 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -32,6 +32,7 @@ import '../../widgets/face_verification_overlay.dart'; import '../../utils/snackbar.dart'; import '../../widgets/gemini_animated_text_field.dart'; import '../../widgets/gemini_button.dart'; +import '../../widgets/multi_select_picker.dart'; import '../../widgets/responsive_body.dart'; class AttendanceScreen extends ConsumerStatefulWidget { @@ -1652,8 +1653,12 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { class _LogbookEntry { _LogbookEntry({ required this.name, + required this.userId, + required this.dutyScheduleId, required this.shift, required this.date, + required this.checkInAt, + required this.checkOutAt, required this.checkIn, required this.checkOut, required this.duration, @@ -1676,8 +1681,12 @@ class _LogbookEntry { }); final String name; + final String userId; + final String? dutyScheduleId; final String shift; final DateTime date; + final DateTime checkInAt; + final DateTime? checkOutAt; final String checkIn; final String checkOut; final String duration; @@ -1708,48 +1717,20 @@ class _LogbookEntry { return elapsed.inMinutes <= 10; } - factory _LogbookEntry.fromLog( - AttendanceLog log, - Map byId, [ - Map? byScheduleId, - ]) { - final p = byId[log.userId]; - // duty_schedules.shift_type is the authoritative source; attendance_logs - // may not store it (no shift_type column in older DB schemas). - final shiftType = - byScheduleId?[log.dutyScheduleId]?.shiftType ?? log.shiftType; - return _LogbookEntry( - name: p?.fullName ?? log.userId, - shift: _shiftLabelFromType(shiftType), - date: log.checkInAt, - checkIn: AppTime.formatTime(log.checkInAt), - checkOut: log.isCheckedOut ? AppTime.formatTime(log.checkOutAt!) : '—', - duration: log.isCheckedOut - ? _fmtDur(log.checkOutAt!.difference(log.checkInAt)) - : 'On duty', - status: log.isCheckedOut ? 'Completed' : 'On duty', - isAbsent: false, - verificationStatus: log.verificationStatus, - logId: log.id, - logUserId: log.userId, - enrolledFaceUrl: p?.facePhotoUrl, - checkInVerificationFaceUrl: log.checkInVerificationPhotoUrl, - checkOutVerificationFaceUrl: log.checkOutVerificationPhotoUrl, - justification: log.justification, - checkOutJustification: log.checkOutJustification, - checkInLat: log.checkInLat, - checkInLng: log.checkInLng, - checkOutLat: log.checkOutLat, - checkOutLng: log.checkOutLng, - ); - } + // 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 byId) { + factory _LogbookEntry.absent(DutySchedule s, Map byId) { final p = byId[s.userId]; + final name = p is Profile ? p.fullName : (p as String?) ?? s.userId; return _LogbookEntry( - name: p?.fullName ?? s.userId, + name: name, + userId: s.userId, + dutyScheduleId: s.id, shift: _shiftLabelFromType(s.shiftType), date: s.startTime, + checkInAt: s.startTime, + checkOutAt: null, checkIn: '—', checkOut: '—', duration: '—', @@ -1780,15 +1761,79 @@ class _LogbookEntry { } } +/// A shift grouping representing a set of attendance sessions for a single +/// scheduled shift (or leave/absence) in a single day. +class _ShiftGroup { + _ShiftGroup({ + required this.groupKey, + required this.userId, + required this.dutyScheduleId, + required this.name, + required this.shiftLabel, + required this.sessions, + }); + + final String groupKey; + final String userId; + final String? dutyScheduleId; + final String name; + final String shiftLabel; + final List<_LogbookEntry> sessions; + + DateTime get date => sessions.first.date; + + String get checkIn => + sessions.isEmpty ? '—' : AppTime.formatTime(sessions.first.checkInAt); + + String get checkOut { + final checkedOut = sessions.where((s) => s.checkOutAt != null).toList(); + if (checkedOut.isEmpty) return '—'; + final last = checkedOut.reduce( + (a, b) => a.checkOutAt!.isAfter(b.checkOutAt!) ? a : b, + ); + return last.checkOut; + } + + String get duration { + if (sessions.isEmpty) return '—'; + final first = sessions.first.checkInAt; + final checkedOut = sessions.where((s) => s.checkOutAt != null).toList(); + if (checkedOut.isEmpty) return 'On duty'; + final last = checkedOut.reduce( + (a, b) => a.checkOutAt!.isAfter(b.checkOutAt!) ? a : b, + ); + return _LogbookEntry._fmtDur(last.checkOutAt!.difference(first)); + } + + String get status { + if (sessions.any((s) => s.isLeave)) return 'On Leave'; + if (sessions.any((s) => s.isAbsent)) return 'Absent'; + if (sessions.any((s) => s.checkOutAt == null)) return 'On duty'; + return 'Completed'; + } + + /// Whether any session in this group is still in progress. + bool get hasOngoingSession => + sessions.any((s) => !s.isAbsent && !s.isLeave && s.checkOutAt == null); +} + // ──────────────────────────────────────────────── // Tab 2 – Logbook // ──────────────────────────────────────────────── -class _LogbookTab extends ConsumerWidget { +class _LogbookTab extends ConsumerStatefulWidget { const _LogbookTab(); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState<_LogbookTab> createState() => _LogbookTabState(); +} + +class _LogbookTabState extends ConsumerState<_LogbookTab> { + AsyncValue> _entriesAsync = const AsyncValue.loading(); + int? _lastSignature; + + @override + Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; final range = ref.watch(attendanceDateRangeProvider); @@ -1797,11 +1842,15 @@ class _LogbookTab extends ConsumerWidget { final schedulesAsync = ref.watch(dutySchedulesProvider); final leavesAsync = ref.watch(leavesProvider); - final Map profileById = { - for (final p in profilesAsync.valueOrNull ?? []) p.id: p, - }; + final selectedUserIds = ref.watch(attendanceUserFilterProvider); - final now = AppTime.now(); + // Only show users with admin-like roles in the selector. + const allowedRoles = {'admin', 'dispatcher', 'programmer', 'it_staff'}; + final allowedProfiles = + (profilesAsync.valueOrNull ?? []) + .where((p) => allowedRoles.contains(p.role)) + .toList() + ..sort((a, b) => a.fullName.compareTo(b.fullName)); return Column( children: [ @@ -1840,124 +1889,71 @@ class _LogbookTab extends ConsumerWidget { ), ), ), + + // User filter card (multi-select) + if (allowedProfiles.isNotEmpty) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: MultiSelectPicker( + label: 'Personnel', + items: allowedProfiles, + selectedIds: selectedUserIds, + getId: (p) => p.id, + getLabel: (p) => p.fullName, + onChanged: (selected) { + ref.read(attendanceUserFilterProvider.notifier).state = + selected; + }, + ), + ), + ), + ), + ], + const SizedBox(height: 8), Expanded( child: logsAsync.when( data: (logs) { - final filtered = logs.where((log) { - return !log.checkInAt.isBefore(range.start) && - log.checkInAt.isBefore(range.end); - }).toList(); + _recomputeEntriesIfNeeded( + logs: logs, + range: range, + schedules: schedulesAsync.valueOrNull ?? [], + leaves: leavesAsync.valueOrNull ?? [], + profileList: profilesAsync.valueOrNull ?? [], + selectedUserIds: selectedUserIds, + ); - // Build absent entries from past schedules with no logs. - final allSchedules = schedulesAsync.valueOrNull ?? []; - final logScheduleIds = logs.map((l) => l.dutyScheduleId).toSet(); - // Build a lookup: userId → set of date-strings where overtime was rendered. - final overtimeDaysByUser = >{}; - for (final log in logs) { - if (log.shiftType == 'overtime') { - final d = log.checkInAt; - final key = '${d.year}-${d.month}-${d.day}'; - overtimeDaysByUser.putIfAbsent(log.userId, () => {}).add(key); - } - } - final absentSchedules = allSchedules.where((s) { - // Only include schedules whose shift has ended, within - // the selected date range, and with no matching logs. - // Exclude: - // • overtime – shown via attendance_log entries. - // • on_call – standby duty; no check-in obligation. - // • any day where the user rendered overtime. - if (s.shiftType == 'overtime' || s.shiftType == 'on_call') { - return false; - } - if (logScheduleIds.contains(s.id)) return false; - if (!s.endTime.isBefore(now)) return false; - if (s.startTime.isBefore(range.start) || - !s.startTime.isBefore(range.end)) { - return false; - } - // If the user rendered overtime on this calendar day, don't - // mark the normal/night shift schedule as absent. - final d = s.startTime; - final dayKey = '${d.year}-${d.month}-${d.day}'; - if (overtimeDaysByUser[s.userId]?.contains(dayKey) ?? false) { - return false; - } - return true; - }).toList(); - - // Build combined entries: _LogbookEntry sealed type. - // Include leave entries within the date range. - final leaves = leavesAsync.valueOrNull ?? []; - final leaveEntries = leaves - .where((l) { - return l.status == 'approved' && - !l.startTime.isBefore(range.start) && - l.startTime.isBefore(range.end); - }) - .map((l) { - final p = profileById[l.userId]; - return _LogbookEntry( - name: p?.fullName ?? l.userId, - shift: '—', - date: l.startTime, - checkIn: AppTime.formatTime(l.startTime), - checkOut: AppTime.formatTime(l.endTime), - duration: '—', - status: 'On Leave', - isAbsent: false, - isLeave: true, - leaveType: l.leaveType, - ); - }); - - final Map scheduleById = { - for (final s in allSchedules) s.id: s, - }; - - final List<_LogbookEntry> entries = [ - ...filtered.map( - (l) => _LogbookEntry.fromLog(l, profileById, scheduleById), - ), - ...absentSchedules.map( - (s) => _LogbookEntry.absent(s, profileById), - ), - ...leaveEntries, - ]; - // Sort by date descending. - entries.sort((a, b) => b.date.compareTo(a.date)); - - if (entries.isEmpty) { - return Center( - child: Text( - 'No attendance logs for this period.', - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ); - } - - final currentUserId = ref.read(currentUserIdProvider) ?? ''; - - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth >= 700) { - return _buildDataTable( - context, - entries, - currentUserId: currentUserId, - onReverify: (logId) => _reverify(context, ref, logId), + return _entriesAsync.when( + data: (entries) { + if (entries.isEmpty) { + return Center( + child: Text( + 'No attendance logs for this period.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), ); } - return _buildLogList( + + final currentUserId = ref.read(currentUserIdProvider) ?? ''; + + return _buildShiftGroupList( context, entries, currentUserId: currentUserId, onReverify: (logId) => _reverify(context, ref, logId), ); }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Failed to load logs: $e')), ); }, loading: () => const Center(child: CircularProgressIndicator()), @@ -1968,6 +1964,173 @@ class _LogbookTab extends ConsumerWidget { ); } + void _recomputeEntriesIfNeeded({ + required List logs, + required ReportDateRange range, + required List schedules, + required List leaves, + required List profileList, + required List selectedUserIds, + }) { + final signature = Object.hashAll([ + logs.length, + range.start.millisecondsSinceEpoch, + range.end.millisecondsSinceEpoch, + schedules.length, + leaves.length, + profileList.length, + selectedUserIds.length, + selectedUserIds.hashCode, + ]); + + if (signature == _lastSignature) return; + _lastSignature = signature; + + setState(() { + _entriesAsync = const AsyncValue.loading(); + }); + + Future(() { + final profileById = {for (final p in profileList) p.id: p}; + + return _computeEntries( + logs: logs, + range: range, + schedules: schedules, + leaves: leaves, + profileById: profileById, + selectedUserIds: selectedUserIds, + ); + }) + .then((entries) { + if (!mounted) return; + setState(() { + _entriesAsync = AsyncValue.data(entries); + }); + }) + .catchError((e, st) { + if (!mounted) return; + setState(() { + _entriesAsync = AsyncValue.error(e, st); + }); + }); + } + + List<_LogbookEntry> _computeEntries({ + required List logs, + required ReportDateRange range, + required List schedules, + required List leaves, + required Map profileById, + required List selectedUserIds, + }) { + final filtered = logs.where((log) { + return !log.checkInAt.isBefore(range.start) && + log.checkInAt.isBefore(range.end); + }).toList(); + + final logScheduleIds = filtered.map((l) => l.dutyScheduleId).toSet(); + + final overtimeDaysByUser = >{}; + for (final log in filtered) { + if (log.shiftType == 'overtime') { + final d = log.checkInAt; + final key = '${d.year}-${d.month}-${d.day}'; + overtimeDaysByUser.putIfAbsent(log.userId, () => {}).add(key); + } + } + + final now = AppTime.now(); + final absentSchedules = schedules.where((s) { + if (s.shiftType == 'overtime' || s.shiftType == 'on_call') return false; + if (logScheduleIds.contains(s.id)) return false; + if (!s.endTime.isBefore(now)) return false; + if (s.startTime.isBefore(range.start) || + !s.startTime.isBefore(range.end)) { + return false; + } + final d = s.startTime; + final dayKey = '${d.year}-${d.month}-${d.day}'; + if (overtimeDaysByUser[s.userId]?.contains(dayKey) ?? false) { + return false; + } + return true; + }).toList(); + + final leaveEntries = leaves + .where((l) { + return l.status == 'approved' && + !l.startTime.isBefore(range.start) && + l.startTime.isBefore(range.end); + }) + .map((l) { + final profile = profileById[l.userId]; + return _LogbookEntry( + name: profile?.fullName ?? l.userId, + userId: l.userId, + dutyScheduleId: null, + shift: '—', + date: l.startTime, + checkInAt: l.startTime, + checkOutAt: l.endTime, + checkIn: AppTime.formatTime(l.startTime), + checkOut: AppTime.formatTime(l.endTime), + duration: '—', + status: 'On Leave', + isAbsent: false, + isLeave: true, + leaveType: l.leaveType, + enrolledFaceUrl: profile?.facePhotoUrl, + ); + }); + + final scheduleById = {for (final s in schedules) s.id: s}; + + final List<_LogbookEntry> entries = [ + for (final l in filtered) + _LogbookEntry( + name: profileById[l.userId]?.fullName ?? l.userId, + userId: l.userId, + dutyScheduleId: l.dutyScheduleId, + shift: _LogbookEntry._shiftLabelFromType( + scheduleById[l.dutyScheduleId]?.shiftType ?? l.shiftType, + ), + date: l.checkInAt, + checkInAt: l.checkInAt, + checkOutAt: l.checkOutAt, + checkIn: AppTime.formatTime(l.checkInAt), + checkOut: l.isCheckedOut ? AppTime.formatTime(l.checkOutAt!) : '—', + duration: l.isCheckedOut + ? _LogbookEntry._fmtDur(l.checkOutAt!.difference(l.checkInAt)) + : 'On duty', + status: l.isCheckedOut ? 'Completed' : 'On duty', + isAbsent: false, + verificationStatus: l.verificationStatus, + logId: l.id, + logUserId: l.userId, + enrolledFaceUrl: profileById[l.userId]?.facePhotoUrl, + checkInVerificationFaceUrl: l.checkInVerificationPhotoUrl, + checkOutVerificationFaceUrl: l.checkOutVerificationPhotoUrl, + justification: l.justification, + checkOutJustification: l.checkOutJustification, + checkInLat: l.checkInLat, + checkInLng: l.checkInLng, + checkOutLat: l.checkOutLat, + checkOutLng: l.checkOutLng, + ), + ...absentSchedules.map((s) => _LogbookEntry.absent(s, profileById)), + + ...leaveEntries, + ]; + + final filteredEntries = selectedUserIds.isEmpty + ? entries + : entries.where((e) => selectedUserIds.contains(e.userId)).toList(); + + filteredEntries.sort((a, b) => b.date.compareTo(a.date)); + return filteredEntries; + } + void _reverify(BuildContext context, WidgetRef ref, String logId) async { final profile = ref.read(currentProfileProvider).valueOrNull; if (profile == null || !profile.hasFaceEnrolled) { @@ -2008,45 +2171,21 @@ class _LogbookTab extends ConsumerWidget { ); } - Widget _buildDataTable( + Widget _buildShiftGroupList( BuildContext context, List<_LogbookEntry> entries, { required String currentUserId, required void Function(String logId) onReverify, }) { - // Group entries by date. - final grouped = _groupByDate(entries); - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: grouped.entries.map((group) { - return _DateGroupTile( - dateLabel: group.key, - entries: group.value, - useTable: true, - currentUserId: currentUserId, - onReverify: onReverify, - ); - }).toList(), - ), - ); - } + final groupedByDate = _groupByDate(entries); - Widget _buildLogList( - BuildContext context, - List<_LogbookEntry> entries, { - required String currentUserId, - required void Function(String logId) onReverify, - }) { - // Group entries by date. - final grouped = _groupByDate(entries); return ListView( padding: const EdgeInsets.symmetric(horizontal: 16), - children: grouped.entries.map((group) { + children: groupedByDate.entries.map((group) { + final shiftGroups = _groupByShift(group.value); return _DateGroupTile( dateLabel: group.key, - entries: group.value, - useTable: false, + shiftGroups: shiftGroups, currentUserId: currentUserId, onReverify: onReverify, ); @@ -2054,6 +2193,39 @@ class _LogbookTab extends ConsumerWidget { ); } + /// Group list of entries by shift (user + schedule + date). + static List<_ShiftGroup> _groupByShift(List<_LogbookEntry> entries) { + final groups = {}; + + for (final entry in entries) { + final dateKey = AppTime.formatDate(entry.date); + final key = entry.isLeave + ? 'leave|${entry.userId}|$dateKey|${entry.leaveType ?? ''}' + : 'shift|${entry.userId}|${entry.dutyScheduleId ?? ''}|$dateKey'; + + final group = groups.putIfAbsent( + key, + () => _ShiftGroup( + groupKey: key, + userId: entry.userId, + dutyScheduleId: entry.dutyScheduleId, + name: entry.name, + shiftLabel: entry.shift, + sessions: [], + ), + ); + group.sessions.add(entry); + } + + for (final g in groups.values) { + g.sessions.sort((a, b) => a.date.compareTo(b.date)); + } + + final list = groups.values.toList(); + list.sort((a, b) => b.date.compareTo(a.date)); + return list; + } + /// Group sorted entries by formatted date string (preserving order). static Map> _groupByDate( List<_LogbookEntry> entries, @@ -2074,15 +2246,13 @@ class _LogbookTab extends ConsumerWidget { class _DateGroupTile extends StatelessWidget { const _DateGroupTile({ required this.dateLabel, - required this.entries, - required this.useTable, + required this.shiftGroups, required this.currentUserId, required this.onReverify, }); final String dateLabel; - final List<_LogbookEntry> entries; - final bool useTable; + final List<_ShiftGroup> shiftGroups; final String currentUserId; final void Function(String logId) onReverify; @@ -2101,145 +2271,116 @@ class _DateGroupTile extends StatelessWidget { style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), ), subtitle: Text( - '${entries.length} ${entries.length == 1 ? 'entry' : 'entries'}', + '${shiftGroups.length} ${shiftGroups.length == 1 ? 'shift' : 'shifts'}', style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), ), - children: [if (useTable) _buildTable(context) else _buildList(context)], - ), - ); - } - - Widget _buildTable(BuildContext context) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - columns: const [ - DataColumn(label: Text('Staff')), - DataColumn(label: Text('Shift')), - DataColumn(label: Text('Check In')), - DataColumn(label: Text('Check Out')), - DataColumn(label: Text('Duration')), - DataColumn(label: Text('Status')), - DataColumn(label: Text('Verified')), - ], - rows: entries.map((entry) { - final Color statusColor; - if (entry.isLeave) { - statusColor = Colors.teal; - } else if (entry.isAbsent) { - statusColor = Colors.red; - } else if (entry.status == 'On duty') { - statusColor = Colors.orange; - } else { - statusColor = Colors.green; - } - - final statusText = entry.isLeave - ? 'On Leave${entry.leaveType != null ? ' (${_leaveLabel(entry.leaveType!)})' : ''}' - : entry.status; - - return DataRow( - cells: [ - DataCell(Text(entry.name)), - DataCell(Text(entry.shift)), - DataCell(Text(entry.checkIn)), - DataCell(Text(entry.checkOut)), - DataCell(Text(entry.duration)), - DataCell( - Text( - statusText, - style: TextStyle( - color: statusColor, - fontWeight: (entry.isAbsent || entry.isLeave) - ? FontWeight.w600 - : null, - ), - ), - ), - DataCell( - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _verificationBadge(context, entry), - if (entry.canReverify(currentUserId)) ...[ - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.refresh, size: 16), - tooltip: 'Re-verify', - onPressed: () => onReverify(entry.logId!), - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 28, - minHeight: 28, - ), - visualDensity: VisualDensity.compact, - ), - ], - ], - ), - ), - ], - ); + children: shiftGroups.map((group) { + return _buildShiftGroupTile(context, group); }).toList(), ), ); } - Widget _buildList(BuildContext context) { + Widget _buildShiftGroupTile(BuildContext context, _ShiftGroup group) { + // 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); + } + final colors = Theme.of(context).colorScheme; - return Column( - children: entries.map((entry) { - return ListTile( - title: Row( - children: [ - Expanded(child: Text(entry.name)), - _verificationBadge(context, entry), - if (entry.canReverify(currentUserId)) - IconButton( - icon: Icon( - Icons.refresh, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - tooltip: 'Re-verify', - onPressed: () => onReverify(entry.logId!), - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 28, - minHeight: 28, - ), - visualDensity: VisualDensity.compact, - ), - ], - ), - subtitle: Text( - entry.isLeave - ? 'On Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}' - : entry.isAbsent - ? 'Absent — no check-in recorded' - : 'Shift: ${entry.shift} · In: ${entry.checkIn}${entry.checkOut != "—" ? " · Out: ${entry.checkOut}" : " · On duty"}', - ), - trailing: entry.isLeave - ? Chip( - label: const Text('On Leave'), - backgroundColor: Colors.teal.withValues(alpha: 0.15), - ) - : entry.isAbsent - ? Chip( - label: const Text('Absent'), - backgroundColor: colors.errorContainer, - ) - : entry.status == 'On duty' - ? Chip( - label: const Text('On duty'), - backgroundColor: colors.tertiaryContainer, - ) - : Text( - entry.duration, - style: Theme.of(context).textTheme.bodySmall, - ), - ); - }).toList(), + final textTheme = Theme.of(context).textTheme; + + final statusColor = group.status == 'Absent' + ? Colors.red + : group.status == 'On duty' + ? Colors.orange + : group.status == 'On Leave' + ? Colors.teal + : Colors.green; + + return Card( + margin: const EdgeInsets.fromLTRB(12, 8, 12, 0), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + childrenPadding: const EdgeInsets.symmetric(horizontal: 16), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${group.name} · ${group.shiftLabel}', + style: textTheme.titleSmall, + ), + const SizedBox(height: 4), + Text( + 'In: ${group.checkIn} · Out: ${group.checkOut} · ${group.duration}', + style: textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + subtitle: Row( + children: [ + Chip( + label: Text(group.status), + backgroundColor: statusColor.withValues(alpha: 0.15), + ), + ], + ), + children: group.sessions.map((session) { + return _buildSessionTile(context, session); + }).toList(), + ), + ); + } + + Widget _buildSessionTile(BuildContext context, _LogbookEntry entry) { + final colors = Theme.of(context).colorScheme; + final isReverifyable = entry.canReverify(currentUserId); + + final isLeaveOrAbsent = entry.isLeave || entry.isAbsent; + + return ListTile( + title: Row( + children: [ + Expanded(child: Text(entry.name)), + if (!isLeaveOrAbsent) _verificationBadge(context, entry), + if (isReverifyable) + IconButton( + icon: Icon( + Icons.refresh, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + tooltip: 'Re-verify', + onPressed: () => onReverify(entry.logId!), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + visualDensity: VisualDensity.compact, + ), + ], + ), + subtitle: Text( + entry.isLeave + ? 'On Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}' + : entry.isAbsent + ? 'Absent — no check-in recorded' + : 'In: ${entry.checkIn} · Out: ${entry.checkOut} · ${entry.duration}${entry.status == 'On duty' ? ' · On duty' : ''}', + ), + trailing: isLeaveOrAbsent + ? Chip( + label: Text(entry.isLeave ? 'On Leave' : 'Absent'), + backgroundColor: entry.isLeave + ? Colors.teal.withValues(alpha: 0.15) + : colors.errorContainer, + ) + : entry.status == 'On duty' + ? Chip( + label: const Text('On duty'), + backgroundColor: colors.tertiaryContainer, + ) + : Text(entry.duration, style: Theme.of(context).textTheme.bodySmall), ); }