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).
|
/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
|
||||||
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import '../../widgets/face_verification_overlay.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
import '../../widgets/gemini_animated_text_field.dart';
|
import '../../widgets/gemini_animated_text_field.dart';
|
||||||
import '../../widgets/gemini_button.dart';
|
import '../../widgets/gemini_button.dart';
|
||||||
|
import '../../widgets/multi_select_picker.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
|
||||||
class AttendanceScreen extends ConsumerStatefulWidget {
|
class AttendanceScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -1652,8 +1653,12 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||||
class _LogbookEntry {
|
class _LogbookEntry {
|
||||||
_LogbookEntry({
|
_LogbookEntry({
|
||||||
required this.name,
|
required this.name,
|
||||||
|
required this.userId,
|
||||||
|
required this.dutyScheduleId,
|
||||||
required this.shift,
|
required this.shift,
|
||||||
required this.date,
|
required this.date,
|
||||||
|
required this.checkInAt,
|
||||||
|
required this.checkOutAt,
|
||||||
required this.checkIn,
|
required this.checkIn,
|
||||||
required this.checkOut,
|
required this.checkOut,
|
||||||
required this.duration,
|
required this.duration,
|
||||||
|
|
@ -1676,8 +1681,12 @@ class _LogbookEntry {
|
||||||
});
|
});
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
|
final String userId;
|
||||||
|
final String? dutyScheduleId;
|
||||||
final String shift;
|
final String shift;
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
|
final DateTime checkInAt;
|
||||||
|
final DateTime? checkOutAt;
|
||||||
final String checkIn;
|
final String checkIn;
|
||||||
final String checkOut;
|
final String checkOut;
|
||||||
final String duration;
|
final String duration;
|
||||||
|
|
@ -1708,48 +1717,20 @@ class _LogbookEntry {
|
||||||
return elapsed.inMinutes <= 10;
|
return elapsed.inMinutes <= 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
factory _LogbookEntry.fromLog(
|
// NOTE: logbook entry creation is handled in an async provider (so that
|
||||||
AttendanceLog log,
|
// filtering/sorting does not block the UI). Use `_computeLogbookEntries`.
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
factory _LogbookEntry.absent(DutySchedule s, Map<String, Profile> byId) {
|
factory _LogbookEntry.absent(DutySchedule s, Map<String, Object?> byId) {
|
||||||
final p = byId[s.userId];
|
final p = byId[s.userId];
|
||||||
|
final name = p is Profile ? p.fullName : (p as String?) ?? s.userId;
|
||||||
return _LogbookEntry(
|
return _LogbookEntry(
|
||||||
name: p?.fullName ?? s.userId,
|
name: name,
|
||||||
|
userId: s.userId,
|
||||||
|
dutyScheduleId: s.id,
|
||||||
shift: _shiftLabelFromType(s.shiftType),
|
shift: _shiftLabelFromType(s.shiftType),
|
||||||
date: s.startTime,
|
date: s.startTime,
|
||||||
|
checkInAt: s.startTime,
|
||||||
|
checkOutAt: null,
|
||||||
checkIn: '—',
|
checkIn: '—',
|
||||||
checkOut: '—',
|
checkOut: '—',
|
||||||
duration: '—',
|
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
|
// Tab 2 – Logbook
|
||||||
// ────────────────────────────────────────────────
|
// ────────────────────────────────────────────────
|
||||||
|
|
||||||
class _LogbookTab extends ConsumerWidget {
|
class _LogbookTab extends ConsumerStatefulWidget {
|
||||||
const _LogbookTab();
|
const _LogbookTab();
|
||||||
|
|
||||||
@override
|
@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 theme = Theme.of(context);
|
||||||
final colors = theme.colorScheme;
|
final colors = theme.colorScheme;
|
||||||
final range = ref.watch(attendanceDateRangeProvider);
|
final range = ref.watch(attendanceDateRangeProvider);
|
||||||
|
|
@ -1797,11 +1842,15 @@ class _LogbookTab extends ConsumerWidget {
|
||||||
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
||||||
final leavesAsync = ref.watch(leavesProvider);
|
final leavesAsync = ref.watch(leavesProvider);
|
||||||
|
|
||||||
final Map<String, Profile> profileById = {
|
final selectedUserIds = ref.watch(attendanceUserFilterProvider);
|
||||||
for (final p in profilesAsync.valueOrNull ?? []) p.id: p,
|
|
||||||
};
|
|
||||||
|
|
||||||
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(
|
return Column(
|
||||||
children: [
|
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),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: logsAsync.when(
|
child: logsAsync.when(
|
||||||
data: (logs) {
|
data: (logs) {
|
||||||
final filtered = logs.where((log) {
|
_recomputeEntriesIfNeeded(
|
||||||
return !log.checkInAt.isBefore(range.start) &&
|
logs: logs,
|
||||||
log.checkInAt.isBefore(range.end);
|
range: range,
|
||||||
}).toList();
|
schedules: schedulesAsync.valueOrNull ?? [],
|
||||||
|
leaves: leavesAsync.valueOrNull ?? [],
|
||||||
|
profileList: profilesAsync.valueOrNull ?? [],
|
||||||
|
selectedUserIds: selectedUserIds,
|
||||||
|
);
|
||||||
|
|
||||||
// Build absent entries from past schedules with no logs.
|
return _entriesAsync.when(
|
||||||
final allSchedules = schedulesAsync.valueOrNull ?? [];
|
data: (entries) {
|
||||||
final logScheduleIds = logs.map((l) => l.dutyScheduleId).toSet();
|
if (entries.isEmpty) {
|
||||||
// Build a lookup: userId → set of date-strings where overtime was rendered.
|
return Center(
|
||||||
final overtimeDaysByUser = <String, Set<String>>{};
|
child: Text(
|
||||||
for (final log in logs) {
|
'No attendance logs for this period.',
|
||||||
if (log.shiftType == 'overtime') {
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
final d = log.checkInAt;
|
color: colors.onSurfaceVariant,
|
||||||
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 _buildLogList(
|
|
||||||
|
final currentUserId = ref.read(currentUserIdProvider) ?? '';
|
||||||
|
|
||||||
|
return _buildShiftGroupList(
|
||||||
context,
|
context,
|
||||||
entries,
|
entries,
|
||||||
currentUserId: currentUserId,
|
currentUserId: currentUserId,
|
||||||
onReverify: (logId) => _reverify(context, ref, logId),
|
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()),
|
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 {
|
void _reverify(BuildContext context, WidgetRef ref, String logId) async {
|
||||||
final profile = ref.read(currentProfileProvider).valueOrNull;
|
final profile = ref.read(currentProfileProvider).valueOrNull;
|
||||||
if (profile == null || !profile.hasFaceEnrolled) {
|
if (profile == null || !profile.hasFaceEnrolled) {
|
||||||
|
|
@ -2008,45 +2171,21 @@ class _LogbookTab extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDataTable(
|
Widget _buildShiftGroupList(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<_LogbookEntry> entries, {
|
List<_LogbookEntry> entries, {
|
||||||
required String currentUserId,
|
required String currentUserId,
|
||||||
required void Function(String logId) onReverify,
|
required void Function(String logId) onReverify,
|
||||||
}) {
|
}) {
|
||||||
// Group entries by date.
|
final groupedByDate = _groupByDate(entries);
|
||||||
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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
return ListView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
children: grouped.entries.map((group) {
|
children: groupedByDate.entries.map((group) {
|
||||||
|
final shiftGroups = _groupByShift(group.value);
|
||||||
return _DateGroupTile(
|
return _DateGroupTile(
|
||||||
dateLabel: group.key,
|
dateLabel: group.key,
|
||||||
entries: group.value,
|
shiftGroups: shiftGroups,
|
||||||
useTable: false,
|
|
||||||
currentUserId: currentUserId,
|
currentUserId: currentUserId,
|
||||||
onReverify: onReverify,
|
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).
|
/// Group sorted entries by formatted date string (preserving order).
|
||||||
static Map<String, List<_LogbookEntry>> _groupByDate(
|
static Map<String, List<_LogbookEntry>> _groupByDate(
|
||||||
List<_LogbookEntry> entries,
|
List<_LogbookEntry> entries,
|
||||||
|
|
@ -2074,15 +2246,13 @@ class _LogbookTab extends ConsumerWidget {
|
||||||
class _DateGroupTile extends StatelessWidget {
|
class _DateGroupTile extends StatelessWidget {
|
||||||
const _DateGroupTile({
|
const _DateGroupTile({
|
||||||
required this.dateLabel,
|
required this.dateLabel,
|
||||||
required this.entries,
|
required this.shiftGroups,
|
||||||
required this.useTable,
|
|
||||||
required this.currentUserId,
|
required this.currentUserId,
|
||||||
required this.onReverify,
|
required this.onReverify,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String dateLabel;
|
final String dateLabel;
|
||||||
final List<_LogbookEntry> entries;
|
final List<_ShiftGroup> shiftGroups;
|
||||||
final bool useTable;
|
|
||||||
final String currentUserId;
|
final String currentUserId;
|
||||||
final void Function(String logId) onReverify;
|
final void Function(String logId) onReverify;
|
||||||
|
|
||||||
|
|
@ -2101,145 +2271,116 @@ class _DateGroupTile extends StatelessWidget {
|
||||||
style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${entries.length} ${entries.length == 1 ? 'entry' : 'entries'}',
|
'${shiftGroups.length} ${shiftGroups.length == 1 ? 'shift' : 'shifts'}',
|
||||||
style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
|
style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
children: [if (useTable) _buildTable(context) else _buildList(context)],
|
children: shiftGroups.map((group) {
|
||||||
),
|
return _buildShiftGroupTile(context, group);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}).toList(),
|
}).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;
|
final colors = Theme.of(context).colorScheme;
|
||||||
return Column(
|
final textTheme = Theme.of(context).textTheme;
|
||||||
children: entries.map((entry) {
|
|
||||||
return ListTile(
|
final statusColor = group.status == 'Absent'
|
||||||
title: Row(
|
? Colors.red
|
||||||
children: [
|
: group.status == 'On duty'
|
||||||
Expanded(child: Text(entry.name)),
|
? Colors.orange
|
||||||
_verificationBadge(context, entry),
|
: group.status == 'On Leave'
|
||||||
if (entry.canReverify(currentUserId))
|
? Colors.teal
|
||||||
IconButton(
|
: Colors.green;
|
||||||
icon: Icon(
|
|
||||||
Icons.refresh,
|
return Card(
|
||||||
size: 16,
|
margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||||
color: Theme.of(context).colorScheme.primary,
|
child: ExpansionTile(
|
||||||
),
|
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
tooltip: 'Re-verify',
|
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
onPressed: () => onReverify(entry.logId!),
|
title: Column(
|
||||||
padding: EdgeInsets.zero,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
constraints: const BoxConstraints(
|
children: [
|
||||||
minWidth: 28,
|
Text(
|
||||||
minHeight: 28,
|
'${group.name} · ${group.shiftLabel}',
|
||||||
),
|
style: textTheme.titleSmall,
|
||||||
visualDensity: VisualDensity.compact,
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
],
|
Text(
|
||||||
),
|
'In: ${group.checkIn} · Out: ${group.checkOut} · ${group.duration}',
|
||||||
subtitle: Text(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
entry.isLeave
|
color: colors.onSurfaceVariant,
|
||||||
? '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"}',
|
),
|
||||||
),
|
subtitle: Row(
|
||||||
trailing: entry.isLeave
|
children: [
|
||||||
? Chip(
|
Chip(
|
||||||
label: const Text('On Leave'),
|
label: Text(group.status),
|
||||||
backgroundColor: Colors.teal.withValues(alpha: 0.15),
|
backgroundColor: statusColor.withValues(alpha: 0.15),
|
||||||
)
|
),
|
||||||
: entry.isAbsent
|
],
|
||||||
? Chip(
|
),
|
||||||
label: const Text('Absent'),
|
children: group.sessions.map((session) {
|
||||||
backgroundColor: colors.errorContainer,
|
return _buildSessionTile(context, session);
|
||||||
)
|
}).toList(),
|
||||||
: entry.status == 'On duty'
|
),
|
||||||
? Chip(
|
);
|
||||||
label: const Text('On duty'),
|
}
|
||||||
backgroundColor: colors.tertiaryContainer,
|
|
||||||
)
|
Widget _buildSessionTile(BuildContext context, _LogbookEntry entry) {
|
||||||
: Text(
|
final colors = Theme.of(context).colorScheme;
|
||||||
entry.duration,
|
final isReverifyable = entry.canReverify(currentUserId);
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
final isLeaveOrAbsent = entry.isLeave || entry.isAbsent;
|
||||||
);
|
|
||||||
}).toList(),
|
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