Whereaboutes and dashboard IT Staff Pulse enhancements

This commit is contained in:
Marc Rejohn Castillano 2026-03-08 18:08:03 +08:00
parent 9178b438a2
commit f8502f01b6
3 changed files with 136 additions and 71 deletions

BIN
assets/no_team.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@ -379,7 +379,20 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
final hasActiveCheckIn = userLogs.any((l) => !l.isCheckedOut);
final String whereabouts;
if (livePos != null) {
whereabouts = livePos.inPremise ? 'In premise' : 'Outside premise';
final stale =
AppTime.now().difference(livePos.updatedAt) >
const Duration(minutes: 15);
if (stale) {
final diff = AppTime.now().difference(livePos.updatedAt);
final ago = diff.inMinutes < 60
? '${diff.inMinutes}m ago'
: '${diff.inHours}h ago';
whereabouts = livePos.inPremise
? 'Last seen in premise \u00b7 $ago'
: 'Last seen outside \u00b7 $ago';
} else {
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';
@ -944,20 +957,28 @@ class _StaffTableHeader extends StatelessWidget {
flex: isMobile ? 2 : 3,
child: Text('IT Staff', style: style),
),
Expanded(flex: 2, child: Text('Status', style: style)),
Expanded(
flex: 2,
child: Center(child: Text('Status', style: style)),
),
if (!isMobile)
Expanded(flex: 2, child: Text('Whereabouts', style: style)),
Expanded(
flex: 4,
child: Center(child: Text('Whereabouts', style: style)),
),
Expanded(
flex: isMobile ? 1 : 2,
child: Text(isMobile ? 'Tix' : 'Tickets', style: style),
flex: isMobile ? 1 : 1,
child: Center(
child: Text(isMobile ? 'Tix' : 'Tickets', style: style),
),
),
Expanded(
flex: isMobile ? 1 : 2,
child: Text(isMobile ? 'Tsk' : 'Tasks', style: style),
flex: isMobile ? 1 : 1,
child: Center(child: Text(isMobile ? 'Tsk' : 'Tasks', style: style)),
),
Expanded(
flex: isMobile ? 1 : 2,
child: Text(isMobile ? 'Evt' : 'Events', style: style),
flex: isMobile ? 1 : 1,
child: Center(child: Text(isMobile ? 'Evt' : 'Events', style: style)),
),
],
);
@ -1023,15 +1044,24 @@ class _StaffRow extends StatelessWidget {
final valueStyle = Theme.of(context).textTheme.bodySmall;
final isMobile = AppBreakpoints.isMobile(context);
// Team color dot
Widget? teamDot;
if (row.teamColor != null) {
// Team marker: use team color dot when assigned, otherwise a circular no-team image.
final Widget teamMarker;
if (row.teamColor != null && row.teamColor!.isNotEmpty) {
final color = Color(int.parse(row.teamColor!, radix: 16) | 0xFF000000);
teamDot = Container(
teamMarker = Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
} else {
teamMarker = ClipOval(
child: Image.asset(
'assets/no_team.jpg',
width: 14,
height: 14,
fit: BoxFit.cover,
),
);
}
// IT Staff cell: avatar on mobile, name on desktop, with team color dot
@ -1040,7 +1070,8 @@ class _StaffRow extends StatelessWidget {
staffCell = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (teamDot != null) ...[teamDot, const SizedBox(width: 4)],
teamMarker,
const SizedBox(width: 4),
Flexible(
child: Tooltip(
message: row.name,
@ -1057,7 +1088,8 @@ class _StaffRow extends StatelessWidget {
staffCell = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (teamDot != null) ...[teamDot, const SizedBox(width: 6)],
teamMarker,
const SizedBox(width: 6),
Flexible(child: Text(row.name, style: valueStyle)),
],
);
@ -1070,42 +1102,49 @@ class _StaffRow extends StatelessWidget {
Expanded(flex: isMobile ? 2 : 3, child: staffCell),
Expanded(
flex: 2,
child: Align(
alignment: Alignment.centerLeft,
child: _PulseStatusPill(label: row.status),
),
child: Center(child: _PulseStatusPill(label: row.status)),
),
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,
flex: 4,
child: Center(
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
: row.whereabouts.startsWith('Last seen')
? Colors.grey
: null,
fontWeight: FontWeight.w600,
),
),
),
),
Expanded(
flex: isMobile ? 1 : 2,
child: Text(
row.ticketsRespondedToday.toString(),
style: valueStyle,
flex: isMobile ? 1 : 1,
child: Center(
child: Text(
row.ticketsRespondedToday.toString(),
style: valueStyle,
),
),
),
Expanded(
flex: isMobile ? 1 : 2,
child: Text(row.tasksClosedToday.toString(), style: valueStyle),
flex: isMobile ? 1 : 1,
child: Center(
child: Text(row.tasksClosedToday.toString(), style: valueStyle),
),
),
Expanded(
flex: isMobile ? 1 : 2,
child: Text(row.eventsHandledToday.toString(), style: valueStyle),
flex: isMobile ? 1 : 1,
child: Center(
child: Text(row.eventsHandledToday.toString(), style: valueStyle),
),
),
],
),

View File

@ -37,11 +37,19 @@ String _roleLabel(String role) {
};
}
/// Threshold after which a live position is considered stale.
const _staleDuration = Duration(minutes: 15);
String _timeAgo(DateTime dt) {
final diff = AppTime.now().difference(dt);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
return '${diff.inHours}h ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
bool _isStale(DateTime dt) {
return AppTime.now().difference(dt) > _staleDuration;
}
// ---------------------------------------------------------------------------
@ -167,43 +175,49 @@ class _WhereaboutsMap extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Only pin in-premise users outside-geofence users are greyed out
// in the legend and their exact location is not shown on the map.
// Show in-premise users on the map; grey out stale positions.
final inPremise = positions.where((p) => p.inPremise).toList();
final markers = inPremise.map((pos) {
final profile = profileById[pos.userId];
final name = profile?.fullName ?? 'Unknown';
final pinColor = _roleColor(profile?.role);
final stale = _isStale(pos.updatedAt);
final pinColor = stale ? Colors.grey : _roleColor(profile?.role);
return Marker(
point: LatLng(pos.lat, pos.lng),
width: 80,
height: 60,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
child: Opacity(
opacity: stale ? 0.5 : 1.0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
),
],
),
child: Text(
name.split(' ').first,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
color: stale
? Theme.of(context).colorScheme.onSurfaceVariant
: null,
),
],
overflow: TextOverflow.ellipsis,
),
),
child: Text(
name.split(' ').first,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
Icon(Icons.location_pin, size: 28, color: pinColor),
],
Icon(Icons.location_pin, size: 28, color: pinColor),
],
),
),
);
}).toList();
@ -410,9 +424,10 @@ class _StaffLegendTile extends StatelessWidget {
final hasPosition = position != null;
final isInPremise = position?.inPremise ?? false;
final isTrackingOff = !profile.allowTracking;
final isStale = hasPosition && _isStale(position!.updatedAt);
// Determine display state
final bool isActive = hasPosition && isInPremise;
final bool isActive = hasPosition && isInPremise && !isStale;
final bool isGreyedOut = !isActive;
// For tracking-off users without a live position, infer from check-in.
@ -428,10 +443,13 @@ class _StaffLegendTile extends StatelessWidget {
final Color statusColor;
if (isTrackingOff) {
if (hasPosition) {
final timeLabel = _timeAgo(position!.updatedAt);
statusText = isInPremise
? 'In premise (Tracking off)'
: 'Outside premise (Tracking off)';
statusColor = isInPremise ? Colors.green : Colors.grey;
? 'In premise (Tracking off) \u00b7 $timeLabel'
: 'Outside premise (Tracking off) \u00b7 $timeLabel';
statusColor = isStale
? Colors.grey
: (isInPremise ? Colors.green : Colors.grey);
} else if (inferredInPremise) {
statusText = 'In premise (Checked in)';
statusColor = Colors.green;
@ -439,11 +457,17 @@ class _StaffLegendTile extends StatelessWidget {
statusText = 'Tracking off';
statusColor = Colors.grey;
}
} else if (isStale && hasPosition) {
final timeLabel = _timeAgo(position!.updatedAt);
statusText = isInPremise
? 'Last seen in premise \u00b7 $timeLabel'
: 'Last seen outside premise \u00b7 $timeLabel';
statusColor = Colors.grey;
} else if (isActive) {
statusText = 'In premise \u00b7 ${_timeAgo(position!.updatedAt)}';
statusColor = Colors.green;
} else if (hasPosition) {
statusText = 'Outside premise';
statusText = 'Outside premise \u00b7 ${_timeAgo(position!.updatedAt)}';
statusColor = Colors.grey;
} else {
statusText = 'No location data';
@ -453,6 +477,8 @@ class _StaffLegendTile extends StatelessWidget {
final IconData statusIcon;
if (isTrackingOff) {
statusIcon = Icons.location_disabled;
} else if (isStale) {
statusIcon = Icons.location_off;
} else if (isActive) {
statusIcon = Icons.location_on;
} else if (hasPosition) {