Added Team Activity Dashboard
This commit is contained in:
parent
4b63b55812
commit
a39e33bc6b
|
|
@ -29,6 +29,7 @@ import '../../providers/it_service_request_provider.dart';
|
||||||
import '../../providers/teams_provider.dart';
|
import '../../providers/teams_provider.dart';
|
||||||
import '../../models/team.dart';
|
import '../../models/team.dart';
|
||||||
import '../../models/team_member.dart';
|
import '../../models/team_member.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../widgets/reconnect_overlay.dart';
|
import '../../widgets/reconnect_overlay.dart';
|
||||||
import '../../widgets/profile_avatar.dart';
|
import '../../widgets/profile_avatar.dart';
|
||||||
|
|
@ -672,6 +673,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_sectionTitle(context, 'IT Staff Pulse'),
|
_sectionTitle(context, 'IT Staff Pulse'),
|
||||||
const _StaffTable(),
|
const _StaffTable(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_sectionTitle(context, 'Team Activity'),
|
||||||
|
const _StaffActivityChart(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_sectionTitle(context, 'Core Daily KPIs'),
|
_sectionTitle(context, 'Core Daily KPIs'),
|
||||||
_cardGrid(context, [
|
_cardGrid(context, [
|
||||||
|
|
@ -896,8 +900,6 @@ class _MetricCard extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// M3 Expressive: tonal surface container with 16 dp radius, no hard border.
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
|
|
@ -1178,6 +1180,368 @@ class _StaffRow extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _StaffActivityChart extends ConsumerWidget {
|
||||||
|
const _StaffActivityChart();
|
||||||
|
|
||||||
|
static const _avatarDiameter = 30.0;
|
||||||
|
static const _barWidth = 56.0;
|
||||||
|
static const _barSpacing = 14.0;
|
||||||
|
static const _chartHeight = 260.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final metricsAsync = ref.watch(dashboardMetricsProvider);
|
||||||
|
final teamsAsync = ref.watch(teamsProvider);
|
||||||
|
final teamMembersAsync = ref.watch(teamMembersProvider);
|
||||||
|
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppSurfaces.of(context).containerRadius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: metricsAsync.when(
|
||||||
|
data: (metrics) {
|
||||||
|
final staffRows = metrics.staffRows;
|
||||||
|
if (staffRows.isEmpty) {
|
||||||
|
return Text(
|
||||||
|
'No staff activity data available.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final teams = teamsAsync.valueOrNull ?? const <Team>[];
|
||||||
|
final members = teamMembersAsync.valueOrNull ?? const <TeamMember>[];
|
||||||
|
final teamById = {for (final t in teams) t.id: t};
|
||||||
|
final teamIdByUser = {for (final m in members) m.userId: m.teamId};
|
||||||
|
|
||||||
|
// Group rows by team name in the order they appear.
|
||||||
|
final teamGroups = <String, List<StaffRowMetrics>>{};
|
||||||
|
for (final row in staffRows) {
|
||||||
|
final teamId = teamIdByUser[row.userId];
|
||||||
|
final teamName = teamId != null ? teamById[teamId]?.name : null;
|
||||||
|
final label = teamName ?? 'Unassigned';
|
||||||
|
teamGroups.putIfAbsent(label, () => []).add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
final teamColorByName = {for (final t in teams) t.name: t.color};
|
||||||
|
|
||||||
|
final flattened = <_UserBarData>[];
|
||||||
|
final teamLegend = <MapEntry<String, int>>[];
|
||||||
|
for (final entry in teamGroups.entries) {
|
||||||
|
final teamName = entry.key;
|
||||||
|
final rows = entry.value;
|
||||||
|
for (final row in rows) {
|
||||||
|
flattened.add(_UserBarData(teamName: teamName, row: row));
|
||||||
|
}
|
||||||
|
teamLegend.add(MapEntry(teamName, rows.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
Color? parseHexColor(String? hex) {
|
||||||
|
if (hex == null || hex.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
return Color(int.parse(hex, radix: 16) | 0xFF000000);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final totals = flattened
|
||||||
|
.map(
|
||||||
|
(d) =>
|
||||||
|
d.row.ticketsRespondedToday +
|
||||||
|
d.row.tasksClosedToday +
|
||||||
|
d.row.eventsHandledToday,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Use the actual maximum total as the display cap so bar heights
|
||||||
|
// are strictly proportional to their numeric values.
|
||||||
|
final capValue = totals.isEmpty
|
||||||
|
? 1
|
||||||
|
: totals.reduce((a, b) => math.max(a, b));
|
||||||
|
|
||||||
|
final totalHeight = _chartHeight - _avatarDiameter - 24;
|
||||||
|
final barHeightFactor = capValue > 0
|
||||||
|
? totalHeight / math.max(capValue, 1)
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
Widget buildBar(_UserBarData data) {
|
||||||
|
final tickets = data.row.ticketsRespondedToday;
|
||||||
|
final tasks = data.row.tasksClosedToday;
|
||||||
|
final events = data.row.eventsHandledToday;
|
||||||
|
final total = tickets + tasks + events;
|
||||||
|
|
||||||
|
// Linear mapping to the maximum value so heights are proportional
|
||||||
|
// to actual totals.
|
||||||
|
final barHeight = total * barHeightFactor;
|
||||||
|
|
||||||
|
final teamHex = teamColorByName[data.teamName];
|
||||||
|
final teamColor =
|
||||||
|
parseHexColor(teamHex) ?? Theme.of(context).colorScheme.primary;
|
||||||
|
|
||||||
|
final segments = <MapEntry<int, Color>>[];
|
||||||
|
if (tickets > 0) {
|
||||||
|
segments.add(
|
||||||
|
MapEntry(tickets, teamColor.withAlpha((0.9 * 255).round())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tasks > 0) {
|
||||||
|
segments.add(
|
||||||
|
MapEntry(tasks, teamColor.withAlpha((0.7 * 255).round())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (events > 0) {
|
||||||
|
segments.add(
|
||||||
|
MapEntry(events, teamColor.withAlpha((0.5 * 255).round())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure bars never overflow the chart vertical constraints by
|
||||||
|
// clamping the computed bar height and forcing the bar widget to
|
||||||
|
// occupy the same overall chart height. This prevents the
|
||||||
|
// RenderFlex overflow seen when a bar's intrinsic height exceeded
|
||||||
|
// the available space.
|
||||||
|
final maxBarContainerHeight = math.max(
|
||||||
|
0.0,
|
||||||
|
_chartHeight - (_avatarDiameter * 0.5) - 12,
|
||||||
|
);
|
||||||
|
final clampedBarHeight = math.min(barHeight, maxBarContainerHeight);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: _chartHeight,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
children: [
|
||||||
|
// Bar container: fixed (clamped) height so inner
|
||||||
|
// Expanded segments layout into a finite box.
|
||||||
|
Container(
|
||||||
|
width: _barWidth,
|
||||||
|
height: clampedBarHeight,
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
for (final segment in segments)
|
||||||
|
Expanded(
|
||||||
|
flex: segment.key,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
color: segment.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Avatar overlapped on the bar (top center). The
|
||||||
|
// avatar is allowed to overflow slightly above the
|
||||||
|
// bar — that's intentional for the visual style.
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
child: Container(
|
||||||
|
width: _avatarDiameter,
|
||||||
|
height: _avatarDiameter,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Theme.of(context).colorScheme.shadow
|
||||||
|
.withAlpha((0.25 * 255).round()),
|
||||||
|
blurRadius: 5,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Tooltip(
|
||||||
|
message: data.row.name,
|
||||||
|
child: ProfileAvatar(
|
||||||
|
fullName: data.row.name,
|
||||||
|
avatarUrl: data.row.avatarUrl,
|
||||||
|
radius: _avatarDiameter / 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (total > 0)
|
||||||
|
Positioned(
|
||||||
|
bottom: 10,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withAlpha(
|
||||||
|
(0.35 * 255).round(),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
total.toString(),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// (Overflow indicator left in place for future cap logic.)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final barCount = flattened.length;
|
||||||
|
final requiredWidth =
|
||||||
|
barCount * (_barWidth + _barSpacing) - _barSpacing;
|
||||||
|
final showScroll = requiredWidth > constraints.maxWidth;
|
||||||
|
|
||||||
|
final chartRow = Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < flattened.length; i++) ...[
|
||||||
|
buildBar(flattened[i]),
|
||||||
|
if (i < flattened.length - 1)
|
||||||
|
const SizedBox(width: _barSpacing),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build a per-team legend so the X-axis has only one label per team.
|
||||||
|
Widget buildTeamLegend() {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
for (var entry in teamLegend) ...[
|
||||||
|
SizedBox(
|
||||||
|
width:
|
||||||
|
(entry.value * _barWidth) +
|
||||||
|
((entry.value - 1) * _barSpacing),
|
||||||
|
child: Text(
|
||||||
|
entry.key,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: _barSpacing),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left axis width reserved for dynamic numeric labels.
|
||||||
|
const leftAxisWidth = 56.0;
|
||||||
|
|
||||||
|
// Build numeric ticks for the Y-axis (capValue .. 0) and remove
|
||||||
|
// consecutive duplicates that occur when capValue is small.
|
||||||
|
final tickCount = 4;
|
||||||
|
final ticksRaw = List<int>.generate(
|
||||||
|
tickCount,
|
||||||
|
(i) => ((capValue * (tickCount - 1 - i) / (tickCount - 1)))
|
||||||
|
.round(),
|
||||||
|
);
|
||||||
|
final ticks = <int>[];
|
||||||
|
for (final t in ticksRaw) {
|
||||||
|
if (ticks.isEmpty || ticks.last != t) ticks.add(t);
|
||||||
|
}
|
||||||
|
if (ticks.isEmpty || ticks.last != 0) ticks.add(0);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: _chartHeight + 40,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: leftAxisWidth,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Small spacer for avatar overlap area
|
||||||
|
SizedBox(
|
||||||
|
height: (_avatarDiameter / 2).roundToDouble(),
|
||||||
|
),
|
||||||
|
// Ticks aligned within the remaining chart area
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
for (final t in ticks)
|
||||||
|
Text(
|
||||||
|
t.toString(),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
physics: showScroll
|
||||||
|
? const BouncingScrollPhysics()
|
||||||
|
: const NeverScrollableScrollPhysics(),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(height: _chartHeight, child: chartRow),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
buildTeamLegend(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, stack) => Text('Failed to load activity: $error'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserBarData {
|
||||||
|
_UserBarData({required this.teamName, required this.row});
|
||||||
|
|
||||||
|
final String teamName;
|
||||||
|
final StaffRowMetrics row;
|
||||||
|
}
|
||||||
|
|
||||||
/// Status pill with attendance-specific coloring for the IT Staff Pulse table.
|
/// Status pill with attendance-specific coloring for the IT Staff Pulse table.
|
||||||
class _PulseStatusPill extends StatelessWidget {
|
class _PulseStatusPill extends StatelessWidget {
|
||||||
const _PulseStatusPill({required this.label});
|
const _PulseStatusPill({required this.label});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user