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) => (
+ |
+ ))}
+
+
+
+
+
+
+
+
+
+ | User |
+ Message Count |
+
+
+
+ {rows.map((row) => (
+
+ | {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);
+};