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:
parent
2a50d15b14
commit
46ccccc3dd
5 changed files with 270 additions and 23 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
58
frontend/src/components/UserExportButton.jsx
Normal file
58
frontend/src/components/UserExportButton.jsx
Normal 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;
|
||||
86
frontend/src/components/UserReport.jsx
Normal file
86
frontend/src/components/UserReport.jsx
Normal 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 & 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
16
frontend/src/utils/userAggregation.js
Normal file
16
frontend/src/utils/userAggregation.js
Normal 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);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue