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 '../../models/team.dart';
|
||||
import '../../models/team_member.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/reconnect_overlay.dart';
|
||||
import '../../widgets/profile_avatar.dart';
|
||||
|
|
@ -672,6 +673,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||
const SizedBox(height: 16),
|
||||
_sectionTitle(context, 'IT Staff Pulse'),
|
||||
const _StaffTable(),
|
||||
const SizedBox(height: 12),
|
||||
_sectionTitle(context, 'Team Activity'),
|
||||
const _StaffActivityChart(),
|
||||
const SizedBox(height: 20),
|
||||
_sectionTitle(context, 'Core Daily KPIs'),
|
||||
_cardGrid(context, [
|
||||
|
|
@ -896,8 +900,6 @@ class _MetricCard extends ConsumerWidget {
|
|||
),
|
||||
),
|
||||
);
|
||||
|
||||
// M3 Expressive: tonal surface container with 16 dp radius, no hard border.
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
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.
|
||||
class _PulseStatusPill extends StatelessWidget {
|
||||
const _PulseStatusPill({required this.label});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user