Team Color, image compression for attendance verification, improved wherebouts

This commit is contained in:
2026-03-08 12:23:28 +08:00
parent a8751ca728
commit d87b5e73d7
11 changed files with 757 additions and 191 deletions
+167 -36
View File
@@ -24,8 +24,13 @@ import '../../providers/tickets_provider.dart';
import '../../providers/whereabouts_provider.dart';
import '../../providers/workforce_provider.dart';
import '../../providers/it_service_request_provider.dart';
import '../../providers/teams_provider.dart';
import '../../models/team.dart';
import '../../models/team_member.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/reconnect_overlay.dart';
import '../../widgets/profile_avatar.dart';
import '../../widgets/app_breakpoints.dart';
import '../../providers/realtime_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../theme/app_surfaces.dart';
@@ -70,6 +75,8 @@ class StaffRowMetrics {
required this.ticketsRespondedToday,
required this.tasksClosedToday,
required this.eventsHandledToday,
this.avatarUrl,
this.teamColor,
});
final String userId;
@@ -79,6 +86,8 @@ class StaffRowMetrics {
final int ticketsRespondedToday;
final int tasksClosedToday;
final int eventsHandledToday;
final String? avatarUrl;
final String? teamColor;
}
final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
@@ -94,6 +103,8 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
final passSlipsAsync = ref.watch(passSlipsProvider);
final isrAssignmentsAsync = ref.watch(itServiceRequestAssignmentsProvider);
final isrAsync = ref.watch(itServiceRequestsProvider);
final teamsAsync = ref.watch(teamsProvider);
final teamMembersAsync = ref.watch(teamMembersProvider);
final asyncValues = [
ticketsAsync,
@@ -294,13 +305,29 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
const triageWindow = Duration(minutes: 1);
final triageCutoff = now.subtract(triageWindow);
// Pre-index team membership: map user → team color (hex string).
final teams = teamsAsync.valueOrNull ?? const <Team>[];
final teamMembers = teamMembersAsync.valueOrNull ?? const <TeamMember>[];
final teamById = {for (final t in teams) t.id: t};
final teamColorByUser = <String, String?>{};
for (final m in teamMembers) {
final team = teamById[m.teamId];
if (team != null) {
teamColorByUser[m.userId] = team.color;
}
}
// Pre-index schedules, logs, and positions by user for efficient lookup.
// Include schedules starting today AND overnight schedules from yesterday
// that span into today (e.g. on_call 11 PM 7 AM).
final todaySchedulesByUser = <String, List<DutySchedule>>{};
for (final s in schedules) {
// Exclude overtime schedules from regular duty tracking.
if (s.shiftType != 'overtime' &&
!s.startTime.isBefore(startOfDay) &&
s.startTime.isBefore(endOfDay)) {
if (s.shiftType == 'overtime') continue;
final startsToday =
!s.startTime.isBefore(startOfDay) && s.startTime.isBefore(endOfDay);
final overnightFromYesterday =
s.startTime.isBefore(startOfDay) && s.endTime.isAfter(startOfDay);
if (startsToday || overnightFromYesterday) {
todaySchedulesByUser.putIfAbsent(s.userId, () => []).add(s);
}
}
@@ -343,15 +370,22 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
final onTask = staffOnTask.contains(staff.id);
final inTriage = lastMessage != null && lastMessage.isAfter(triageCutoff);
// Whereabouts from live position.
final livePos = positionByUser[staff.id];
final whereabouts = livePos != null
? (livePos.inPremise ? 'In premise' : 'Outside premise')
: '\u2014';
// Attendance-based status.
final userSchedules = todaySchedulesByUser[staff.id] ?? const [];
final userLogs = todayLogsByUser[staff.id] ?? const [];
// Whereabouts from live position, with tracking-off inference.
final livePos = positionByUser[staff.id];
final hasActiveCheckIn = userLogs.any((l) => !l.isCheckedOut);
final String whereabouts;
if (livePos != null) {
whereabouts = livePos.inPremise ? 'In premise' : 'Outside premise';
} else if (!staff.allowTracking) {
// Tracking off — infer from active check-in (geofence validated).
whereabouts = hasActiveCheckIn ? 'In premise' : 'Tracking off';
} else {
whereabouts = '\u2014';
}
final activeLog = userLogs.where((l) => !l.isCheckedOut).firstOrNull;
final completedLogs = userLogs.where((l) => l.isCheckedOut).toList();
@@ -370,7 +404,25 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
? 'In triage'
: 'Off duty';
} else {
final schedule = userSchedules.first;
// Pick the most relevant schedule: prefer one that is currently
// active (now is within startend), then the nearest upcoming one,
// and finally the most recently ended one.
final activeSchedule = userSchedules
.where((s) => !now.isBefore(s.startTime) && now.isBefore(s.endTime))
.firstOrNull;
DutySchedule? upcomingSchedule;
if (activeSchedule == null) {
final upcoming =
userSchedules.where((s) => now.isBefore(s.startTime)).toList()
..sort((a, b) => a.startTime.compareTo(b.startTime));
if (upcoming.isNotEmpty) upcomingSchedule = upcoming.first;
}
final schedule =
activeSchedule ??
upcomingSchedule ??
userSchedules.reduce((a, b) => a.endTime.isAfter(b.endTime) ? a : b);
final isShiftOver = !now.isBefore(schedule.endTime);
final isFullDay =
schedule.endTime.difference(schedule.startTime).inHours >= 6;
@@ -399,10 +451,28 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
}
} else {
// Not checked in yet, no completed logs.
if (isOnCall) {
// ON CALL staff don't need to be on premise or check in at a
// specific time — they only come when needed.
status = isShiftOver ? 'Off duty' : 'ON CALL';
//
// Check whether ANY of the user's schedules is on_call so we
// can apply on-call logic even when the "most relevant"
// schedule that was selected is a regular shift.
final anyOnCallActive = userSchedules.any(
(s) =>
s.shiftType == 'on_call' &&
!now.isBefore(s.startTime) &&
now.isBefore(s.endTime),
);
final onlyOnCallSchedules = userSchedules.every(
(s) => s.shiftType == 'on_call',
);
if (anyOnCallActive) {
// An on_call shift is currently in its window → ON CALL.
status = 'ON CALL';
} else if (isOnCall || onlyOnCallSchedules) {
// Selected schedule is on_call, or user has ONLY on_call
// schedules with none currently active → Off duty (between
// on-call shifts). On-call staff can never be Late/Absent.
status = 'Off duty';
} else if (isShiftOver) {
// Shift ended with no check-in at all → Absent.
status = 'Absent';
@@ -435,6 +505,8 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
isrAsync.valueOrNull ?? [],
now,
),
avatarUrl: staff.avatarUrl,
teamColor: teamColorByUser[staff.id],
);
}).toList();
@@ -865,14 +937,28 @@ class _StaffTableHeader extends StatelessWidget {
final style = Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700);
final isMobile = AppBreakpoints.isMobile(context);
return Row(
children: [
Expanded(flex: 3, child: Text('IT Staff', style: style)),
Expanded(
flex: isMobile ? 2 : 3,
child: Text('IT Staff', style: style),
),
Expanded(flex: 2, child: Text('Status', style: style)),
Expanded(flex: 2, child: Text('Whereabouts', style: style)),
Expanded(flex: 2, child: Text('Tickets', style: style)),
Expanded(flex: 2, child: Text('Tasks', style: style)),
Expanded(flex: 2, child: Text('Events', style: style)),
if (!isMobile)
Expanded(flex: 2, child: Text('Whereabouts', style: style)),
Expanded(
flex: isMobile ? 1 : 2,
child: Text(isMobile ? 'Tix' : 'Tickets', style: style),
),
Expanded(
flex: isMobile ? 1 : 2,
child: Text(isMobile ? 'Tsk' : 'Tasks', style: style),
),
Expanded(
flex: isMobile ? 1 : 2,
child: Text(isMobile ? 'Evt' : 'Events', style: style),
),
],
);
}
@@ -935,11 +1021,53 @@ class _StaffRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final valueStyle = Theme.of(context).textTheme.bodySmall;
final isMobile = AppBreakpoints.isMobile(context);
// Team color dot
Widget? teamDot;
if (row.teamColor != null) {
final color = Color(int.parse(row.teamColor!, radix: 16) | 0xFF000000);
teamDot = Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
}
// IT Staff cell: avatar on mobile, name on desktop, with team color dot
Widget staffCell;
if (isMobile) {
staffCell = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (teamDot != null) ...[teamDot, const SizedBox(width: 4)],
Flexible(
child: Tooltip(
message: row.name,
child: ProfileAvatar(
fullName: row.name,
avatarUrl: row.avatarUrl,
radius: 14,
),
),
),
],
);
} else {
staffCell = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (teamDot != null) ...[teamDot, const SizedBox(width: 6)],
Flexible(child: Text(row.name, style: valueStyle)),
],
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(flex: 3, child: Text(row.name, style: valueStyle)),
Expanded(flex: isMobile ? 2 : 3, child: staffCell),
Expanded(
flex: 2,
child: Align(
@@ -947,33 +1075,36 @@ class _StaffRow extends StatelessWidget {
child: _PulseStatusPill(label: row.status),
),
),
Expanded(
flex: 2,
child: Text(
row.whereabouts,
style: valueStyle?.copyWith(
color: row.whereabouts == 'In premise'
? Colors.green
: row.whereabouts == 'Outside premise'
? Colors.orange
: null,
fontWeight: FontWeight.w600,
if (!isMobile)
Expanded(
flex: 2,
child: Text(
row.whereabouts,
style: valueStyle?.copyWith(
color: row.whereabouts == 'In premise'
? Colors.green
: row.whereabouts == 'Outside premise'
? Colors.grey
: row.whereabouts == 'Tracking off'
? Colors.grey
: null,
fontWeight: FontWeight.w600,
),
),
),
),
Expanded(
flex: 2,
flex: isMobile ? 1 : 2,
child: Text(
row.ticketsRespondedToday.toString(),
style: valueStyle,
),
),
Expanded(
flex: 2,
flex: isMobile ? 1 : 2,
child: Text(row.tasksClosedToday.toString(), style: valueStyle),
),
Expanded(
flex: 2,
flex: isMobile ? 1 : 2,
child: Text(row.eventsHandledToday.toString(), style: valueStyle),
),
],