Enhanced Logbook layout
This commit is contained in:
parent
84837c4bf2
commit
4eaf9444f0
|
|
@ -22,6 +22,9 @@ final attendanceDateRangeProvider = StateProvider<ReportDateRange>((ref) {
|
|||
);
|
||||
});
|
||||
|
||||
/// Filter for logbook users (multi-select). If empty, all users are shown.
|
||||
final attendanceUserFilterProvider = StateProvider<List<String>>((ref) => []);
|
||||
|
||||
/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
|
||||
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
|
|
|
|||
|
|
@ -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<String, Profile> byId, [
|
||||
Map<String, DutySchedule>? 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<String, Profile> byId) {
|
||||
factory _LogbookEntry.absent(DutySchedule s, Map<String, Object?> 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<List<_LogbookEntry>> _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<String, Profile> 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<Profile>(
|
||||
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 = <String, Set<String>>{};
|
||||
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<String, DutySchedule> 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<AttendanceLog> logs,
|
||||
required ReportDateRange range,
|
||||
required List<DutySchedule> schedules,
|
||||
required List<LeaveOfAbsence> leaves,
|
||||
required List<Profile> profileList,
|
||||
required List<String> 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<AttendanceLog> logs,
|
||||
required ReportDateRange range,
|
||||
required List<DutySchedule> schedules,
|
||||
required List<LeaveOfAbsence> leaves,
|
||||
required Map<String, Profile> profileById,
|
||||
required List<String> 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 = <String, Set<String>>{};
|
||||
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 = <String, _ShiftGroup>{};
|
||||
|
||||
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<String, List<_LogbookEntry>> _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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user