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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-06 13:12:49 +01:00
parent 2a50d15b14
commit 46ccccc3dd
5 changed files with 270 additions and 23 deletions

View file

@ -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 (
<div className="dashboard">
<div className="tab-strip">
<button
className={activeTab === 'volume' ? 'active' : ''}
onClick={() => setActiveTab('volume')}
>
Volume Report
</button>
<button
className={activeTab === 'users' ? 'active' : ''}
onClick={() => setActiveTab('users')}
>
Per-User Report
</button>
</div>
<div className="controls-row">
<FilterPanel
processedUserData={processedUserData}
@ -250,31 +268,51 @@ const Dashboard = ({ conversations, messages }) => {
dateRange={filters.dateRange}
onDateRangeChange={handleDateRangeChange}
/>
<ExportButton
filteredConversations={filteredConversations}
{activeTab === 'volume' && (
<ExportButton
filteredConversations={filteredConversations}
filteredMessages={filteredMessages}
filters={filters}
uniqueAssistants={uniqueAssistants}
assistantDisplayToIdMap={assistantDisplayToIdMap}
/>
)}
{activeTab === 'users' && (
<UserExportButton
filteredMessages={filteredMessages}
processedUserData={processedUserData}
filters={filters}
/>
)}
</div>
{activeTab === 'volume' && (
<>
<VolumeGraph
filteredConversations={filteredConversations}
filteredMessages={filteredMessages}
selectedAssistant={filters.assistant}
uniqueAssistants={uniqueAssistants}
assistantIdToNameMap={assistantIdToDisplayMap}
assistantDisplayToIdMap={assistantDisplayToIdMap}
/>
<div className="metrics-definitions">
<h3>Key Metrics Definitions</h3>
<div className="definition-item">
<strong>Conversation:</strong> A single, complete user interaction session from start to finish.
</div>
<div className="definition-item">
<strong>Message:</strong> Each individual back-and-forth exchange (user prompt and AI response) within a conversation.
</div>
</div>
</>
)}
{activeTab === 'users' && (
<UserReport
filteredMessages={filteredMessages}
processedUserData={processedUserData}
filters={filters}
uniqueAssistants={uniqueAssistants}
assistantDisplayToIdMap={assistantDisplayToIdMap}
/>
</div>
<VolumeGraph
filteredConversations={filteredConversations}
filteredMessages={filteredMessages}
selectedAssistant={filters.assistant}
uniqueAssistants={uniqueAssistants} // This is now IDs
assistantIdToNameMap={assistantIdToDisplayMap}
assistantDisplayToIdMap={assistantDisplayToIdMap}
/>
<div className="metrics-definitions">
<h3>Key Metrics Definitions</h3>
<div className="definition-item">
<strong>Conversation:</strong> A single, complete user interaction session from start to finish.
</div>
<div className="definition-item">
<strong>Message:</strong> Each individual back-and-forth exchange (user prompt and AI response) within a conversation.
</div>
</div>
)}
</div>
);
};

View file

@ -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 (
<div className="export-button-container">
<button
className="export-button"
onClick={handleExport}
disabled={!filteredMessages.length}
>
Export User Report
</button>
</div>
);
};
export default UserExportButton;

View file

@ -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 (
<div className="volume-graph">
<div className="graph-header">
<h2>Conversation &amp; Message Volume (By User)</h2>
</div>
<p className="user-report-date-label">{dateLabel}</p>
{rows.length === 0 ? (
<div className="no-data">No messages for the selected filters.</div>
) : (
<>
<div className="graph-container" style={{ marginBottom: '1.5rem' }}>
<ResponsiveContainer width="100%" height={380}>
<BarChart data={chartData} margin={{ top: 10, right: 20, left: 0, bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="name"
tick={{ fontSize: 11 }}
angle={-45}
textAnchor="end"
interval={0}
/>
<YAxis allowDecimals={false} />
<Tooltip formatter={(value) => [`${value} messages`, 'Messages']} />
<Legend verticalAlign="top" />
<Bar dataKey="count" name="Messages">
{chartData.map((_, index) => (
<Cell key={index} fill={BAR_COLORS[index % BAR_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="graph-container">
<table className="user-report-table">
<thead>
<tr>
<th>User</th>
<th className="user-report-count-col">Message Count</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.userId}>
<td>{row.displayName}</td>
<td className="user-report-count-col"><strong>{row.count}</strong></td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
);
};
export default UserReport;

View file

@ -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;

View file

@ -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);
};