From 46ccccc3ddd41c0c2a93b9c2b5bbcbce369aff40 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Wed, 6 May 2026 13:12:49 +0100 Subject: [PATCH] Add Per-User Report tab with bar chart, table, and CSV export New tab in Dashboard switches between the existing Volume Report and a Per-User view showing message counts aggregated by User_ID. Reuses existing filteredMessages, processedUserData, date/org filters. CSV export matches the format Michael sends to the BAIC UK client. Co-Authored-By: Claude Opus 4.7 --- frontend/src/components/Dashboard.jsx | 84 +++++++++++++------ frontend/src/components/UserExportButton.jsx | 58 +++++++++++++ frontend/src/components/UserReport.jsx | 86 ++++++++++++++++++++ frontend/src/index.css | 49 +++++++++++ frontend/src/utils/userAggregation.js | 16 ++++ 5 files changed, 270 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/UserExportButton.jsx create mode 100644 frontend/src/components/UserReport.jsx create mode 100644 frontend/src/utils/userAggregation.js diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index d72588f..71bb35d 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -3,13 +3,17 @@ import FilterPanel from './FilterPanel'; import DatePickerComponent from './DatePickerComponent'; import VolumeGraph from './VolumeGraph'; import ExportButton from './ExportButton'; +import UserReport from './UserReport'; +import UserExportButton from './UserExportButton'; import { parseISO, isWithinInterval, isValid } from 'date-fns'; import { getAssistantDisplayName, hasAssistantFriendlyName } from '../services/assistantMapping'; import { processUserData } from '../services/userMapping'; const Dashboard = ({ conversations, messages }) => { console.log(`Dashboard received ${conversations.length} conversations and ${messages.length} messages`); - + + const [activeTab, setActiveTab] = useState('volume'); + // Initialize filter state const [filters, setFilters] = useState({ organization: 'All Users', @@ -238,6 +242,20 @@ const Dashboard = ({ conversations, messages }) => { return (
+
+ + +
{ dateRange={filters.dateRange} onDateRangeChange={handleDateRangeChange} /> - + )} + {activeTab === 'users' && ( + + )} +
+ {activeTab === 'volume' && ( + <> + +
+

Key Metrics Definitions

+
+ Conversation: A single, complete user interaction session from start to finish. +
+
+ Message: Each individual back-and-forth exchange (user prompt and AI response) within a conversation. +
+
+ + )} + {activeTab === 'users' && ( + -
- -
-

Key Metrics Definitions

-
- Conversation: A single, complete user interaction session from start to finish. -
-
- Message: Each individual back-and-forth exchange (user prompt and AI response) within a conversation. -
-
+ )} ); }; diff --git a/frontend/src/components/UserExportButton.jsx b/frontend/src/components/UserExportButton.jsx new file mode 100644 index 0000000..3bd8e02 --- /dev/null +++ b/frontend/src/components/UserExportButton.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { format } from 'date-fns'; +import { aggregateMessagesByUser } from '../utils/userAggregation'; + +const UserExportButton = ({ filteredMessages, processedUserData, filters }) => { + const handleExport = () => { + const rows = aggregateMessagesByUser(filteredMessages, processedUserData); + if (!rows.length) { + alert('No data to export with the current filters.'); + return; + } + + const org = filters.organization || 'All Users'; + const startISO = filters.dateRange?.start ? format(filters.dateRange.start, 'yyyy-MM-dd') : ''; + const endISO = filters.dateRange?.end ? format(filters.dateRange.end, 'yyyy-MM-dd') : ''; + const orgKey = org.toLowerCase().replace(/\s+/g, '_'); + + const csvEscape = (v) => { + const s = String(v ?? ''); + return s.includes(',') || s.includes('"') || s.includes('\n') + ? `"${s.replaceAll('"', '""')}"` + : s; + }; + + let csv = ''; + csv += `Organization,${csvEscape(org)}\n`; + csv += `Start Date,${csvEscape(startISO)}\n`; + csv += `End Date,${csvEscape(endISO)}\n`; + csv += 'User,Message Count\n'; + for (const row of rows) { + csv += `${csvEscape(row.displayName)},${row.count}\n`; + } + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `user_message_counts_${orgKey}_${startISO}_to_${endISO}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + }; + + return ( +
+ +
+ ); +}; + +export default UserExportButton; diff --git a/frontend/src/components/UserReport.jsx b/frontend/src/components/UserReport.jsx new file mode 100644 index 0000000..26b3d0c --- /dev/null +++ b/frontend/src/components/UserReport.jsx @@ -0,0 +1,86 @@ +import React, { useMemo } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell +} from 'recharts'; +import { format } from 'date-fns'; +import { aggregateMessagesByUser } from '../utils/userAggregation'; + +const BAR_COLORS = [ + '#60a5fa', '#93c5fd', '#a78bfa', '#c4b5fd', + '#fca5a5', '#fda4af', '#fdba74', '#fcd34d', + '#86efac', '#34d399', '#2dd4bf', '#22c55e', +]; + +const UserReport = ({ filteredMessages, processedUserData, filters }) => { + const rows = useMemo( + () => aggregateMessagesByUser(filteredMessages, processedUserData), + [filteredMessages, processedUserData] + ); + + const startISO = filters.dateRange?.start ? format(filters.dateRange.start, 'yyyy-MM-dd') : null; + const endISO = filters.dateRange?.end ? format(filters.dateRange.end, 'yyyy-MM-dd') : null; + const dateLabel = startISO && endISO + ? `Date Range: ${startISO} to ${endISO} (${filters.organization || 'All Users'})` + : `All time (${filters.organization || 'All Users'})`; + + const chartData = rows.map((r) => ({ name: r.displayName, count: r.count })); + + return ( +
+
+

Conversation & Message Volume (By User)

+
+

{dateLabel}

+ + {rows.length === 0 ? ( +
No messages for the selected filters.
+ ) : ( + <> +
+ + + + + + [`${value} messages`, 'Messages']} /> + + + {chartData.map((_, index) => ( + + ))} + + + +
+ +
+ + + + + + + + + {rows.map((row) => ( + + + + + ))} + +
UserMessage Count
{row.displayName}{row.count}
+
+ + )} +
+ ); +}; + +export default UserReport; diff --git a/frontend/src/index.css b/frontend/src/index.css index a816ffa..af28ea2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -237,6 +237,55 @@ select { color: #4b5563 !important; } +.tab-strip { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + border-bottom: 2px solid #e5e7eb; + padding-bottom: 0.5rem; +} + +.tab-strip button { + border-radius: 4px 4px 0 0; + border-bottom: none; +} + +.user-report-date-label { + font-size: 0.875rem; + color: #6b7280; + margin: 0 0 1rem 0; +} + +.user-report-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.user-report-table thead th { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 2px solid #e5e7eb; + color: #1f2937; + font-weight: 600; + background-color: #f8fafc; +} + +.user-report-table tbody tr:hover { + background-color: #f1f5f9; +} + +.user-report-table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #e5e7eb; + color: #374151; +} + +.user-report-count-col { + text-align: right; + width: 160px; +} + .metrics-definitions { margin-top: 2rem; padding: 1.5rem; diff --git a/frontend/src/utils/userAggregation.js b/frontend/src/utils/userAggregation.js new file mode 100644 index 0000000..261cf92 --- /dev/null +++ b/frontend/src/utils/userAggregation.js @@ -0,0 +1,16 @@ +export const aggregateMessagesByUser = (filteredMessages, processedUserData) => { + const counts = new Map(); + for (const msg of filteredMessages) { + const uid = msg.User_ID || 'Unknown'; + counts.set(uid, (counts.get(uid) || 0) + 1); + } + const { friendlyNameMap = {}, organizationMap = {} } = processedUserData || {}; + return [...counts.entries()] + .map(([userId, count]) => ({ + userId, + displayName: friendlyNameMap[userId] || userId, + organization: organizationMap[userId] || 'Other', + count, + })) + .sort((a, b) => b.count - a.count); +};