Team Color, image compression for attendance verification, improved wherebouts
This commit is contained in:
@@ -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 start–end), 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),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user