wulf-pulse/app/engagement/page.tsx
lorentz 758b7e7f15 feat(engagement): replace Graph email counts with real-time mimecast data
- Broaden mimecast retention from 30 days to 18 months rolling
- Re-enable mimecast-sync schedule (was disabled since March 17)
- Full sync triggered: 35,559 messages loaded for last 30 days
- Users list API: LATERAL join on mimecast_messages for emails_sent/received
- User detail API: add emails{d7,d30,d90} field from mimecast
- Engagement page: prefer mimecast email counts in detail panel sub-label

Graph API has 48-72hr reporting lag; mimecast is same-day
2026-06-02 20:25:14 -04:00

1300 lines
57 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useState, useCallback } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Users,
RefreshCw,
AlertCircle,
Mail,
MessageSquare,
Video,
Clock,
TrendingUp,
ChevronLeft,
ChevronRight,
Phone,
Moon,
} from 'lucide-react';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
CartesianGrid,
} from 'recharts';
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
interface SummaryData {
totalStaff: number;
activeThisPeriod: number;
avgHoursWorked: string;
avgBillableHours: string;
avgTeamsMeetings: string;
avgEmailsSent: string;
lastSynced: string | null;
configured: boolean;
}
interface UserRow {
graphUserId: string;
displayName: string;
email: string;
jobTitle: string | null;
department: string | null;
autotaskResourceId: number | null;
hoursWorked: number;
billableHours: number;
teamsMessages: number;
teamsCallCount: number;
meetingsAttended: number;
meetingsOrganized: number;
meetingsWithExternal: number;
audioDurationSeconds: number;
meetingDurationSeconds: number;
emailsSent: number;
emailsReceived: number;
lastActivity: string | null;
zoomCallCount: number;
zoomClientCallCount: number;
zoomCallDurationSeconds: number;
zoomMeetingCount: number;
zoomClientMeetingCount: number;
}
interface Pagination {
total: number;
page: number;
pageSize: number;
totalPages: number;
}
interface UserDetail {
user: {
id: string;
displayName: string;
email: string;
jobTitle: string | null;
department: string | null;
accountEnabled: boolean;
autotaskResourceId: number | null;
};
snapshots: Array<{
period_type: string;
period_end: string;
teams_chat_messages: number;
teams_private_messages: number;
teams_calls: number;
teams_meetings_attended: number;
teams_meetings_organized: number;
emails_sent: number;
emails_received: number;
emails_read: number;
last_activity_date: string | null;
after_hours_messages: number;
}>;
hours: {
d7: { total: number; billable: number };
d30: { total: number; billable: number };
d90: { total: number; billable: number };
} | null;
recentEntries: Array<{
entry_date: string;
hours_worked: number;
billable: boolean;
notes: string | null;
title: string | null;
company_name: string | null;
}>;
recentTeamsMeetings: Array<{
subject: string | null;
startTime: string;
durationMinutes: number | null;
attendeeCount: number;
clientAttendeeCount: number;
hasClientAttendees: boolean;
clientCompanies: string[];
participantNames: string[];
matchedEntries: Array<{
hours_worked: number;
billable: boolean;
notes: string | null;
title: string | null;
company_name: string | null;
start_date_time: string | null;
end_date_time: string | null;
}>;
}>;
emails: Record<'d7' | 'd30' | 'd90', { sent: number; received: number }> | null;
meetingCounts: { total: number; withClients: number };
zoom: {
calls: Record<'d7' | 'd30' | 'd90', { total: number; client: number; outbound: number; inbound: number; durationSeconds: number }>;
meetings: Record<'d7' | 'd30' | 'd90', { total: number; withClients: number }>;
topClients: Array<{ companyName: string; callCount: number; meetingCount: number }>;
recentCalls: Array<{
direction: string;
status: string;
otherPartyName: string | null;
otherPartyNumber: string | null;
startTime: string;
durationSeconds: number | null;
companyName: string | null;
}>;
recentMeetings: Array<{
topic: string | null;
startTime: string;
endTime: string | null;
durationMinutes: number | null;
participantCount: number;
clientParticipantCount: number;
hasClientAttendees: boolean;
externalParticipantNames: string[];
clientCompanies: string[];
matchedEntries: Array<{
hours_worked: number;
billable: boolean;
notes: string | null;
title: string | null;
company_name: string | null;
start_date_time: string | null;
end_date_time: string | null;
}>;
}>;
} | null;
dailyActivity: Array<{
date: string;
meetings: number;
zoomCalls: number;
hours: number;
meetingMins: number;
}>;
afterHours: {
messages: number;
meetings: number;
messagesPct: number;
meetingsPct: number;
};
peerMax: {
hours: number;
billableHours: number;
meetings: number;
clientMeetings: number;
messages: number;
emails: number;
calls: number;
} | null;
trend: {
hours: number;
billable: number;
meetings: number;
calls: number;
};
}
function ActivityHeatmap({ data, periodDays }: {
data: UserDetail['dailyActivity'];
periodDays: number;
}) {
// Build a map of date -> data
const byDate = new Map(data.map(d => [d.date, d]));
// Generate all days in the period
const days: string[] = [];
for (let i = periodDays - 1; i >= 0; i--) {
const d = new Date();
d.setUTCHours(0, 0, 0, 0);
d.setUTCDate(d.getUTCDate() - i);
days.push(d.toISOString().slice(0, 10));
}
// Score each day: meetings * 2 + zoomCalls + hours (capped for color scaling)
const scores = days.map(d => {
const row = byDate.get(d);
if (!row) return 0;
return row.meetings * 2 + row.zoomCalls + Math.min(row.hours, 8);
});
const maxScore = Math.max(...scores, 1);
const getStyle = (score: number): React.CSSProperties => {
if (score === 0) return { backgroundColor: 'var(--muted)' };
const intensity = score / maxScore;
if (intensity < 0.25) return { backgroundColor: 'rgb(191 219 254)' }; // blue-200
if (intensity < 0.5) return { backgroundColor: 'rgb(96 165 250)' }; // blue-400
if (intensity < 0.75) return { backgroundColor: 'rgb(37 99 235)' }; // blue-600
return { backgroundColor: 'rgb(30 64 175)' }; // blue-800
};
const getTooltip = (date: string, score: number) => {
const row = byDate.get(date);
if (!row || score === 0) return date;
const parts = [];
if (row.meetings > 0) parts.push(`${row.meetings} meetings (${row.meetingMins}m)`);
if (row.zoomCalls > 0) parts.push(`${row.zoomCalls} calls`);
if (row.hours > 0) parts.push(`${row.hours.toFixed(1)}h logged`);
return `${date}\n${parts.join(' · ')}`;
};
// Group into weeks (columns)
const firstDow = new Date(days[0]).getUTCDay(); // 0=Sun
const cells = Array(firstDow).fill(null).concat(days);
const weeks: (string | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
const dayLabels = ['S','M','T','W','T','F','S'];
return (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium text-sm">Activity Heatmap</h3>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>less</span>
{[
{ backgroundColor: 'var(--muted)' },
{ backgroundColor: 'rgb(191 219 254)' },
{ backgroundColor: 'rgb(96 165 250)' },
{ backgroundColor: 'rgb(37 99 235)' },
{ backgroundColor: 'rgb(30 64 175)' },
].map((s, i) => (
<div key={i} className="w-3 h-3 rounded-sm" style={s} />
))}
<span>more</span>
</div>
</div>
<div className="flex gap-1">
{/* Day-of-week labels */}
<div className="flex flex-col gap-0.5 mr-0.5">
{dayLabels.map((l, i) => (
<div key={i} className="h-3.5 w-3 text-[9px] text-muted-foreground leading-[14px]">{l}</div>
))}
</div>
{/* Week columns */}
<div className="flex gap-0.5 flex-1 overflow-x-auto">
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col gap-0.5">
{week.map((date, di) => {
if (!date) return <div key={di} className="w-3.5 h-3.5" />;
const idx = days.indexOf(date);
const score = idx >= 0 ? scores[idx] : 0;
return (
<div
key={di}
className="w-3.5 h-3.5 rounded-sm cursor-default"
style={getStyle(score)}
title={getTooltip(date, score)}
/>
);
})}
</div>
))}
</div>
</div>
</div>
);
}
function ActivityBubbles({ metrics }: {
metrics: Array<{
label: string;
sub: string;
value: number;
peerMax: number;
prevValue: number | null;
color: string;
bg: string;
border: string;
}>;
}) {
const minPx = 68;
const maxPx = 128;
return (
<div className="flex flex-wrap gap-4 items-end justify-start py-1">
{metrics.map((m, i) => {
const ratio = m.peerMax > 0 ? Math.min(m.value / m.peerMax, 1) : 0;
const size = Math.round(minPx + (maxPx - minPx) * Math.sqrt(ratio));
const opacity = ratio < 0.04 ? 0.35 : 1;
// Trend
const hasTrend = m.prevValue !== null && m.prevValue >= 0;
const delta = hasTrend ? m.value - m.prevValue! : 0;
const pct = hasTrend && m.prevValue! > 0
? Math.round((delta / m.prevValue!) * 100)
: null;
const trendUp = delta > 0;
const trendDown = delta < 0;
const trendColor = trendUp ? '#16a34a' : trendDown ? '#dc2626' : '#6b7280';
const trendArrow = trendUp ? '▲' : trendDown ? '▼' : '▸';
const trendLabel = pct !== null
? `${trendArrow} ${Math.abs(pct)}%`
: delta !== 0 ? `${trendArrow} ${Math.abs(delta)}` : '—';
// Peer rank label
const peerPct = m.peerMax > 0 ? Math.round((m.value / m.peerMax) * 100) : 0;
return (
<div key={i} className="flex flex-col items-center gap-1.5">
<div
className="flex flex-col items-center justify-center rounded-full shrink-0 transition-all relative"
style={{
width: size,
height: size,
backgroundColor: m.bg,
border: `2px solid ${m.border}`,
opacity,
}}
title={`${m.label}: ${m.value} · Peer max: ${m.peerMax}${m.sub ? ' · ' + m.sub : ''}`}
>
<span className="text-[9px] font-semibold uppercase tracking-wide leading-none mb-0.5 opacity-70" style={{ color: m.color }}>{m.label}</span>
<span className="text-xl font-bold tabular-nums leading-none" style={{ color: m.color }}>{m.value}</span>
{m.sub && <span className="text-[9px] leading-none mt-0.5 opacity-75" style={{ color: m.color }}>{m.sub}</span>}
{/* Peer percentile arc indicator */}
<span
className="absolute bottom-1.5 text-[8px] font-medium opacity-60"
style={{ color: m.color }}
>
{peerPct}% of top
</span>
</div>
{/* Trend pill below bubble */}
{hasTrend && (
<span
className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full"
style={{
color: trendColor,
backgroundColor: trendUp ? 'rgba(22,163,74,0.12)' : trendDown ? 'rgba(220,38,38,0.12)' : 'rgba(107,114,128,0.12)',
}}
>
{trendLabel}
</span>
)}
</div>
);
})}
</div>
);
}
function formatDuration(seconds: number | null): string {
if (!seconds || seconds <= 0) return '—';
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
const rem = m % 60;
return rem > 0 ? `${h}h ${rem}m` : `${h}h`;
}
function timeAgo(dateStr: string | null): string {
if (!dateStr) return 'Never';
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function NotConfiguredBanner() {
return (
<Card className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20">
<CardContent className="pt-6">
<div className="flex gap-3">
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-800 dark:text-amber-300">Microsoft Graph not configured</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
Add <code className="bg-amber-100 dark:bg-amber-900 px-1 rounded text-xs">MSGRAPH_CLIENT_ID</code>,{' '}
<code className="bg-amber-100 dark:bg-amber-900 px-1 rounded text-xs">MSGRAPH_CLIENT_SECRET</code>, and{' '}
<code className="bg-amber-100 dark:bg-amber-900 px-1 rounded text-xs">MSGRAPH_TENANT_ID</code> to your environment to enable engagement data.
</p>
</div>
</div>
</CardContent>
</Card>
);
}
function SummaryCard({
title,
value,
sub,
icon: Icon,
}: {
title: string;
value: string | number;
sub?: string;
icon: React.ElementType;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
</CardContent>
</Card>
);
}
export default function EngagementPage() {
const tz = useUserTimezone();
const [period, setPeriod] = useState<'D7' | 'D30' | 'D90'>('D7');
const [activeTab, setActiveTab] = useState<'overview' | 'by-employee'>('by-employee');
const [summary, setSummary] = useState<SummaryData | null>(null);
const [users, setUsers] = useState<UserRow[]>([]);
const [pagination, setPagination] = useState<Pagination | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [sortField, setSortField] = useState('billable_hours');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [loading, setLoading] = useState(true);
const [usersLoading, setUsersLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [userDetail, setUserDetail] = useState<UserDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [zoomDetailView, setZoomDetailView] = useState<'summary' | 'calls' | 'meetings'>('summary');
const [teamsDetailView, setTeamsDetailView] = useState<'summary' | 'meetings'>('summary');
const fetchSummary = useCallback(async () => {
try {
const res = await fetch(`/api/engagement/summary?period=${period}`);
const data = await res.json();
setSummary(data);
} catch (error) {
console.error('Failed to fetch engagement summary:', error);
}
}, [period]);
const fetchUsers = useCallback(async () => {
setUsersLoading(true);
try {
const res = await fetch(
`/api/engagement/users?period=${period}&sort=${sortField}&order=${sortOrder}&page=${currentPage}`
);
const data = await res.json();
setUsers(data.users ?? []);
setPagination(data.pagination ?? null);
} catch (error) {
console.error('Failed to fetch engagement users:', error);
} finally {
setUsersLoading(false);
}
}, [period, sortField, sortOrder, currentPage]);
const fetchAll = useCallback(async () => {
setLoading(true);
await Promise.all([fetchSummary(), fetchUsers()]);
setLoading(false);
}, [fetchSummary, fetchUsers]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const handleSync = async () => {
setSyncing(true);
try {
await fetch('/api/engagement/sync', { method: 'POST' });
const poll = setInterval(async () => {
const res = await fetch('/api/engagement/sync').then(r => r.json());
if (!res.isSyncing) {
clearInterval(poll);
setSyncing(false);
fetchAll();
}
}, 3000);
setTimeout(() => {
clearInterval(poll);
setSyncing(false);
fetchAll();
}, 300000);
} catch {
setSyncing(false);
}
};
const handleUserClick = async (userId: string) => {
setSelectedUser(userId);
setDetailLoading(true);
try {
const res = await fetch(`/api/engagement/user/${userId}?period=${period}`);
const data = await res.json();
setUserDetail(data);
} catch (error) {
console.error('Failed to fetch user detail:', error);
} finally {
setDetailLoading(false);
}
};
const handleSort = (field: string) => {
if (sortField === field) {
setSortOrder(o => (o === 'desc' ? 'asc' : 'desc'));
} else {
setSortField(field);
setSortOrder('desc');
}
setCurrentPage(1);
};
// Prepare chart data — top 10 by billable hours
const topByHours = [...users]
.sort((a, b) => b.billableHours - a.billableHours)
.slice(0, 10)
.map(u => ({
name: u.displayName?.split(' ')[0] ?? u.email,
'Billable Hours': parseFloat(u.billableHours.toFixed(1)),
'Non-Billable': parseFloat((u.hoursWorked - u.billableHours).toFixed(1)),
}));
const topByTeams = [...users]
.sort((a, b) => (b.teamsMessages + b.meetingsAttended) - (a.teamsMessages + a.meetingsAttended))
.slice(0, 7)
.map(u => ({
name: u.displayName?.split(' ')[0] ?? u.email,
Meetings: u.meetingsAttended,
Messages: u.teamsMessages,
}));
const periodLabel = { D7: '7 days', D30: '30 days', D90: '90 days' }[period];
if (loading) {
return (
<div className="container mx-auto px-6 py-6 space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
<Skeleton className="h-80" />
</div>
);
}
return (
<div className="container mx-auto px-6 py-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Employee Engagement</h1>
<p className="text-muted-foreground text-sm mt-1">
Staff activity across Autotask, Microsoft Teams, and Email
</p>
</div>
<div className="flex items-center gap-3">
{summary?.lastSynced && (
<span className="text-sm text-muted-foreground">
Last sync: {timeAgo(summary.lastSynced)}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={handleSync}
disabled={syncing || !summary?.configured}
>
<RefreshCw className={`h-4 w-4 mr-2 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Sync Now'}
</Button>
</div>
</div>
{summary && !summary.configured && <NotConfiguredBanner />}
{/* Period selector */}
<div className="flex gap-2">
{(['D7', 'D30', 'D90'] as const).map(p => (
<Button
key={p}
variant={period === p ? 'default' : 'outline'}
size="sm"
onClick={() => { setPeriod(p); setCurrentPage(1); }}
>
{{ D7: '7 days', D30: '30 days', D90: '90 days' }[p]}
</Button>
))}
</div>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'overview' | 'by-employee')} className="space-y-6">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="by-employee">By Employee</TabsTrigger>
</TabsList>
{/* ── Overview Tab ── */}
<TabsContent value="overview" className="space-y-6">
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<SummaryCard
title="Total Staff"
value={summary?.totalStaff ?? 0}
sub="Active Azure AD accounts"
icon={Users}
/>
<SummaryCard
title={`Active (${periodLabel})`}
value={summary?.activeThisPeriod ?? 0}
sub="Had Teams or email activity"
icon={TrendingUp}
/>
<SummaryCard
title="Avg Billable Hours"
value={summary?.avgBillableHours ?? '—'}
sub={`Per person over ${periodLabel}`}
icon={Clock}
/>
<SummaryCard
title="Avg Meetings Attended"
value={summary?.avgTeamsMeetings ?? '—'}
sub={`Teams meetings over ${periodLabel}`}
icon={Video}
/>
</div>
{/* Charts */}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Top 10 Billable Hours</CardTitle>
</CardHeader>
<CardContent>
{topByHours.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No data yet run a sync first</p>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={topByHours} margin={{ top: 4, right: 8, bottom: 24, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} angle={-30} textAnchor="end" />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Legend />
<Bar dataKey="Billable Hours" stackId="a" fill="var(--primary)" />
<Bar dataKey="Non-Billable" stackId="a" fill="var(--muted-foreground)" />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Top 7 Teams Activity</CardTitle>
</CardHeader>
<CardContent>
{topByTeams.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No data yet run a sync first</p>
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={topByTeams} margin={{ top: 4, right: 40, bottom: 24, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} angle={-30} textAnchor="end" />
<YAxis yAxisId="msg" tick={{ fontSize: 11 }} />
<YAxis yAxisId="mtg" orientation="right" tick={{ fontSize: 11 }} />
<Tooltip />
<Legend />
<Bar yAxisId="msg" dataKey="Messages" fill="#10b981" />
<Bar yAxisId="mtg" dataKey="Meetings" fill="#6366f1" />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* ── By Employee Tab ── */}
<TabsContent value="by-employee" className="space-y-4">
<Card>
<CardContent className="pt-0">
{usersLoading ? (
<div className="space-y-2 pt-4">
{[...Array(8)].map((_, i) => <Skeleton key={i} className="h-10" />)}
</div>
) : users.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Users className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">No engagement data yet run a sync first</p>
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:text-foreground"
onClick={() => handleSort('display_name')}
>
Name {sortField === 'display_name' && (sortOrder === 'desc' ? '↓' : '↑')}
</TableHead>
<TableHead className="hidden md:table-cell">Title</TableHead>
<TableHead
className="cursor-pointer hover:text-foreground text-right"
onClick={() => handleSort('hours_worked')}
>
Hours {sortField === 'hours_worked' && (sortOrder === 'desc' ? '↓' : '↑')}
</TableHead>
<TableHead
className="cursor-pointer hover:text-foreground text-right"
onClick={() => handleSort('billable_hours')}
>
Billable {sortField === 'billable_hours' && (sortOrder === 'desc' ? '↓' : '↑')}
</TableHead>
<TableHead
className="cursor-pointer hover:text-foreground text-right hidden lg:table-cell"
onClick={() => handleSort('teams_meetings_attended')}
>
Meetings {sortField === 'teams_meetings_attended' && (sortOrder === 'desc' ? '↓' : '↑')}
</TableHead>
<TableHead className="text-right hidden lg:table-cell text-muted-foreground text-xs">
Ext. Mtgs
</TableHead>
<TableHead className="text-right hidden xl:table-cell text-muted-foreground text-xs">
Mtg Hrs
</TableHead>
<TableHead
className="cursor-pointer hover:text-foreground text-right hidden xl:table-cell"
onClick={() => handleSort('teams_chat_messages')}
>
Messages {sortField === 'teams_chat_messages' && (sortOrder === 'desc' ? '↓' : '↑')}
</TableHead>
<TableHead
className="cursor-pointer hover:text-foreground text-right hidden xl:table-cell"
onClick={() => handleSort('emails_sent')}
>
Emails {sortField === 'emails_sent' && (sortOrder === 'desc' ? '↓' : '↑')}
</TableHead>
<TableHead className="text-right hidden xl:table-cell">Last Active</TableHead>
<TableHead className="text-right hidden xl:table-cell text-muted-foreground text-xs">Zoom Calls</TableHead>
<TableHead className="text-right hidden xl:table-cell text-muted-foreground text-xs">Zoom Mtgs</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(user => (
<TableRow
key={user.graphUserId}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleUserClick(user.graphUserId)}
>
<TableCell>
<div className="font-medium">{user.displayName}</div>
<div className="text-xs text-muted-foreground">{user.email}</div>
</TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
{user.jobTitle || '—'}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{user.hoursWorked.toFixed(1)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{user.billableHours > 0 ? (
<span className="text-green-600 dark:text-green-400 font-medium">
{user.billableHours.toFixed(1)}
</span>
) : (
<span className="text-muted-foreground">0.0</span>
)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums hidden lg:table-cell">
{user.meetingsAttended}
</TableCell>
<TableCell className="text-right text-sm tabular-nums hidden lg:table-cell">
{user.meetingsWithExternal > 0 ? (
<span className="text-blue-600 dark:text-blue-400 font-medium">
{user.meetingsWithExternal}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums hidden xl:table-cell">
{user.audioDurationSeconds > 0
? (user.audioDurationSeconds / 3600).toFixed(1) + 'h'
: <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell className="text-right text-sm tabular-nums hidden xl:table-cell">
{user.teamsMessages}
</TableCell>
<TableCell className="text-right text-sm tabular-nums hidden xl:table-cell">
{user.emailsSent}
</TableCell>
<TableCell className="text-right text-xs text-muted-foreground hidden xl:table-cell">
{user.lastActivity
? new Date(user.lastActivity).toLocaleDateString(undefined, { timeZone: tz })
: '—'}
</TableCell>
<TableCell className="text-right text-sm tabular-nums hidden xl:table-cell">
{user.zoomCallCount > 0 ? (
<span>
{user.zoomCallCount}
{user.zoomClientCallCount > 0 && (
<span className="text-blue-600 dark:text-blue-400 ml-1 text-xs">
({user.zoomClientCallCount}c)
</span>
)}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums hidden xl:table-cell">
{user.zoomMeetingCount > 0 ? (
<span>
{user.zoomMeetingCount}
{user.zoomClientMeetingCount > 0 && (
<span className="text-blue-600 dark:text-blue-400 ml-1 text-xs">
({user.zoomClientMeetingCount}c)
</span>
)}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between pt-4">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}
{Math.min(pagination.page * pagination.pageSize, pagination.total)} of {pagination.total}
</p>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
disabled={currentPage === 1}
onClick={() => setCurrentPage(p => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage === pagination.totalPages}
onClick={() => setCurrentPage(p => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* User detail modal */}
<Dialog open={!!selectedUser} onOpenChange={() => { setSelectedUser(null); setUserDetail(null); setZoomDetailView('summary'); setTeamsDetailView('summary'); }}>
<DialogContent className="w-[90vw] max-w-4xl flex flex-col p-0 gap-0" style={{ maxHeight: '90vh' }}>
<DialogHeader className="shrink-0 px-6 pt-6 pb-4 border-b">
<DialogTitle>
{detailLoading ? 'Loading…' : userDetail?.user.displayName ?? 'User Detail'}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6 pr-8">
{detailLoading && (
<div className="space-y-3 p-2">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-10" />)}
</div>
)}
{!detailLoading && userDetail && (() => {
const pKey = period.toLowerCase() as 'd7' | 'd30' | 'd90';
const snapKey = period as 'D7' | 'D30' | 'D90';
const periodLabel = { D7: '7 days', D30: '30 days', D90: '90 days' }[snapKey];
const snap = userDetail.snapshots.find(s => s.period_type === snapKey);
const hrs = userDetail.hours?.[pKey];
return (
<div className="space-y-5 pt-4">
{/* Compact user info + key stats row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
<div className="md:col-span-2">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-0.5">Email</p>
<p className="font-medium">{userDetail.user.email}</p>
</div>
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-0.5">Title</p>
<p className="font-medium">{userDetail.user.jobTitle || '—'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-0.5">Department</p>
<p className="font-medium">{userDetail.user.department || '—'}</p>
</div>
</div>
{/* Activity bubble visualization */}
<div className="rounded-lg border p-4 bg-muted/30">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">{periodLabel} Activity vs. Team</p>
<ActivityBubbles metrics={[
...(hrs ? [{
label: 'Hours',
sub: `${hrs.billable.toFixed(0)}h billable`,
value: Math.round(hrs.total * 10) / 10,
peerMax: userDetail.peerMax?.hours || (period === 'D7' ? 40 : period === 'D30' ? 160 : 480),
prevValue: userDetail.trend?.hours ?? null,
color: '#16a34a',
bg: 'rgba(22,163,74,0.12)',
border: 'rgba(22,163,74,0.4)',
}] : []),
...(hrs && hrs.total > 0 ? [{
label: 'Billable%',
sub: `${hrs.billable.toFixed(0)}h of ${hrs.total.toFixed(0)}h`,
value: Math.round((hrs.billable / hrs.total) * 100),
peerMax: 100,
prevValue: userDetail.trend?.hours > 0
? Math.round((userDetail.trend.billable / userDetail.trend.hours) * 100)
: null,
color: '#0d9488',
bg: 'rgba(13,148,136,0.12)',
border: 'rgba(13,148,136,0.4)',
}] : []),
{
label: 'Meetings',
sub: userDetail.meetingCounts.withClients > 0 ? `${userDetail.meetingCounts.withClients} w/clients` : '',
value: userDetail.meetingCounts.total,
peerMax: userDetail.peerMax?.meetings || (period === 'D7' ? 20 : period === 'D30' ? 60 : 180),
prevValue: userDetail.trend?.meetings ?? null,
color: '#2563eb',
bg: 'rgba(37,99,235,0.12)',
border: 'rgba(37,99,235,0.4)',
},
...(snap ? [{
label: 'Messages',
sub: userDetail.emails?.[pKey] ? `${userDetail.emails[pKey].sent} emails sent` : `${snap.emails_sent} emails`,
value: snap.teams_chat_messages + snap.teams_private_messages,
peerMax: userDetail.peerMax?.messages || (period === 'D7' ? 200 : period === 'D30' ? 800 : 2400),
prevValue: null,
color: '#7c3aed',
bg: 'rgba(124,58,237,0.12)',
border: 'rgba(124,58,237,0.4)',
}] : []),
...(userDetail.zoom && userDetail.zoom.calls[pKey].total > 0 ? [{
label: 'Calls',
sub: `${userDetail.zoom.calls[pKey].client} client`,
value: userDetail.zoom.calls[pKey].total,
peerMax: userDetail.peerMax?.calls || (period === 'D7' ? 30 : period === 'D30' ? 100 : 300),
prevValue: userDetail.trend?.calls ?? null,
color: '#ea580c',
bg: 'rgba(234,88,12,0.12)',
border: 'rgba(234,88,12,0.4)',
}] : []),
]} />
{snap?.last_activity_date && (
<p className="text-xs text-muted-foreground mt-3">Last activity: {new Date(snap.last_activity_date).toLocaleDateString(undefined, { timeZone: tz })}</p>
)}
</div>
{/* Activity heatmap */}
{userDetail.dailyActivity?.length > 0 && (
<ActivityHeatmap
data={userDetail.dailyActivity}
periodDays={period === 'D7' ? 7 : period === 'D90' ? 90 : 30}
/>
)}
{/* After Hours Activity */}
{(userDetail.afterHours?.messages > 0 || userDetail.afterHours?.meetings > 0) && (() => {
const ah = userDetail.afterHours;
const highThreshold = 30; // % of activity that's after-hours to trigger warning
const isHigh = ah.messagesPct >= highThreshold || ah.meetingsPct >= highThreshold;
return (
<div className={`rounded-lg border p-4 ${isHigh ? 'border-amber-400/60 bg-amber-50/50 dark:bg-amber-950/20' : 'bg-muted/20'}`}>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm flex items-center gap-2">
<Moon className={`h-4 w-4 ${isHigh ? 'text-amber-500' : 'text-muted-foreground'}`} />
After Hours Activity
<span className="text-xs font-normal text-muted-foreground">(5:30 PM 7:00 AM ET)</span>
</h3>
{isHigh && (
<Badge variant="outline" className="text-xs border-amber-400 text-amber-600 dark:text-amber-400">
High after-hours
</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-muted-foreground mb-0.5">Teams Messages</p>
<div className="flex items-baseline gap-2">
<span className={`text-2xl font-bold tabular-nums ${isHigh && ah.messagesPct >= highThreshold ? 'text-amber-600 dark:text-amber-400' : ''}`}>
{ah.messages}
</span>
{ah.messagesPct > 0 && (
<span className="text-xs text-muted-foreground">{ah.messagesPct}% of total</span>
)}
</div>
{ah.messages === 0 && (
<p className="text-xs text-muted-foreground mt-1">No after-hours messages or Chat.Read.All permission not granted</p>
)}
</div>
<div>
<p className="text-xs text-muted-foreground mb-0.5">Meetings</p>
<div className="flex items-baseline gap-2">
<span className={`text-2xl font-bold tabular-nums ${isHigh && ah.meetingsPct >= highThreshold ? 'text-amber-600 dark:text-amber-400' : ''}`}>
{ah.meetings}
</span>
{ah.meetingsPct > 0 && (
<span className="text-xs text-muted-foreground">{ah.meetingsPct}% of total</span>
)}
</div>
</div>
</div>
</div>
);
})()}
{/* Teams meetings */}
{userDetail.recentTeamsMeetings !== undefined && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm">Teams Meetings ({periodLabel})</h3>
{userDetail.recentTeamsMeetings.length > 0 && (
<Badge variant="outline" className="text-xs">
{userDetail.recentTeamsMeetings.length} shown
{userDetail.recentTeamsMeetings.filter(m => m.hasClientAttendees).length > 0 && (
<span className="text-blue-600 ml-1">
· {userDetail.recentTeamsMeetings.filter(m => m.hasClientAttendees).length} with clients
</span>
)}
</Badge>
)}
</div>
{userDetail.recentTeamsMeetings.length > 0 ? (
<div className="space-y-0 max-h-64 overflow-y-auto rounded-md border divide-y">
{[...userDetail.recentTeamsMeetings].sort((a, b) => {
const scoreA = (a.hasClientAttendees ? 2 : 0) + (a.matchedEntries?.length > 0 ? 1 : 0);
const scoreB = (b.hasClientAttendees ? 2 : 0) + (b.matchedEntries?.length > 0 ? 1 : 0);
return scoreB - scoreA || new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
}).map((mtg, i) => {
const d = new Date(mtg.startTime);
return (
<div key={i} className="px-3 py-2 text-sm hover:bg-muted/30">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium leading-snug truncate">{mtg.subject || 'Untitled'}</span>
{mtg.hasClientAttendees && (
<Badge variant="outline" className="text-xs h-4 px-1 shrink-0 text-blue-600 border-blue-400">client</Badge>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">
{d.toLocaleDateString(undefined, { timeZone: tz })} {d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: tz })}
{mtg.durationMinutes != null && ` · ${mtg.durationMinutes}m`}
</span>
</div>
{mtg.clientCompanies.length > 0 && (
<p className="text-xs text-blue-600 dark:text-blue-400 mt-0.5 truncate">{mtg.clientCompanies.join(', ')}</p>
)}
{mtg.matchedEntries?.length > 0 && (
<div className="mt-1.5 space-y-0.5">
{mtg.matchedEntries.map((te, j) => (
<div key={j} className="flex items-center gap-2 text-xs bg-muted/50 rounded px-2 py-1">
<Clock className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="font-medium tabular-nums">{Number(te.hours_worked).toFixed(1)}h</span>
{te.billable && <span className="text-green-600 dark:text-green-400">billable</span>}
{te.company_name && <span className="text-muted-foreground">{te.company_name}</span>}
{(te.notes || te.title) && (
<span className="text-muted-foreground truncate">{(te.notes || te.title || '').split('\n')[0]}</span>
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground py-3">No meetings found in the last {periodLabel}</p>
)}
</div>
)}
{/* Zoom Activity */}
{userDetail.zoom && (userDetail.zoom.calls[pKey].total > 0 || userDetail.zoom.meetings[pKey].total > 0) && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-sm flex items-center gap-2">
<Phone className="h-4 w-4 text-muted-foreground" />
Zoom Activity
</h3>
<div className="flex gap-1">
{(['summary', 'calls', 'meetings'] as const).map(v => (
<Button
key={v}
variant={zoomDetailView === v ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setZoomDetailView(v)}
>
{{ summary: 'Summary', calls: 'Calls', meetings: 'Meetings' }[v]}
</Button>
))}
</div>
</div>
{/* Summary view */}
{zoomDetailView === 'summary' && (() => {
const calls = userDetail.zoom!.calls[pKey];
const meetings = userDetail.zoom!.meetings[pKey];
return (
<div className="rounded-lg border p-3">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 text-sm">
<div className="flex items-center gap-1">
<Phone className="h-3.5 w-3.5 text-muted-foreground" />
<span>{calls.total} calls</span>
</div>
{calls.client > 0 && (
<div className="flex items-center gap-1">
<Users className="h-3.5 w-3.5 text-blue-500" />
<span className="text-blue-600 dark:text-blue-400">{calls.client} client calls</span>
</div>
)}
{calls.durationSeconds > 0 && (
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span>{(calls.durationSeconds / 3600).toFixed(1)}h on calls</span>
</div>
)}
<div className="flex items-center gap-1">
<Video className="h-3.5 w-3.5 text-muted-foreground" />
<span>{meetings.total} Zoom meetings</span>
</div>
{meetings.withClients > 0 && (
<div className="flex items-center gap-1">
<Users className="h-3.5 w-3.5 text-blue-500" />
<span className="text-blue-600 dark:text-blue-400">{meetings.withClients} with clients</span>
</div>
)}
<div className="text-xs text-muted-foreground">
{calls.outbound} {calls.inbound}
</div>
</div>
</div>
);
})()}
{/* Calls detail view */}
{zoomDetailView === 'calls' && (
<div className="space-y-1 max-h-64 overflow-y-auto">
{userDetail.zoom.recentCalls.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No calls in the last 90 days</p>
) : userDetail.zoom.recentCalls.map((call, i) => (
<div key={i} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
<div className="min-w-0 flex items-center gap-2">
<span className="text-muted-foreground text-xs w-3 shrink-0">
{call.direction === 'outbound' ? '↑' : '↓'}
</span>
<div className="min-w-0">
<span className="font-medium truncate">
{call.otherPartyName || call.otherPartyNumber || 'Unknown'}
</span>
{call.companyName && (
<span className="text-muted-foreground"> · {call.companyName}</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-2 text-xs text-muted-foreground">
<span>{formatDuration(call.durationSeconds)}</span>
<span>{new Date(call.startTime).toLocaleDateString(undefined, { timeZone: tz })}</span>
</div>
</div>
))}
</div>
)}
{/* Meetings detail view */}
{zoomDetailView === 'meetings' && (
<div className="space-y-0 max-h-64 overflow-y-auto rounded-md border divide-y">
{userDetail.zoom.recentMeetings.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No meetings in the last {periodLabel}</p>
) : [...userDetail.zoom.recentMeetings].sort((a, b) => {
const scoreA = (a.hasClientAttendees ? 2 : 0) + (a.matchedEntries?.length > 0 ? 1 : 0);
const scoreB = (b.hasClientAttendees ? 2 : 0) + (b.matchedEntries?.length > 0 ? 1 : 0);
return scoreB - scoreA || new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
}).map((mtg, i) => {
const d = new Date(mtg.startTime);
return (
<div key={i} className="px-3 py-2 text-sm hover:bg-muted/30">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium leading-snug truncate">{mtg.topic || 'Untitled meeting'}</span>
{mtg.hasClientAttendees && (
<Badge variant="outline" className="text-xs h-4 px-1 shrink-0 text-blue-600 border-blue-400">client</Badge>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0">
{d.toLocaleDateString(undefined, { timeZone: tz })} {d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: tz })}
{mtg.durationMinutes != null && ` · ${mtg.durationMinutes}m`}
</span>
</div>
{mtg.clientCompanies.length > 0 && (
<p className="text-xs text-blue-600 dark:text-blue-400 mt-0.5 truncate">{mtg.clientCompanies.join(', ')}</p>
)}
{mtg.externalParticipantNames.length > 0 && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{mtg.externalParticipantNames.slice(0, 4).join(', ')}
{mtg.externalParticipantNames.length > 4 && ` +${mtg.externalParticipantNames.length - 4} more`}
</p>
)}
{mtg.matchedEntries?.length > 0 && (
<div className="mt-1.5 space-y-0.5">
{mtg.matchedEntries.map((te, j) => (
<div key={j} className="flex items-center gap-2 text-xs bg-muted/50 rounded px-2 py-1">
<Clock className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="font-medium tabular-nums">{Number(te.hours_worked).toFixed(1)}h</span>
{te.billable && <span className="text-green-600 dark:text-green-400">billable</span>}
{te.company_name && <span className="text-muted-foreground">{te.company_name}</span>}
{(te.notes || te.title) && (
<span className="text-muted-foreground truncate">{(te.notes || te.title || '').split('\n')[0]}</span>
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})()}
</div>
</DialogContent>
</Dialog>
</div>
);
}