'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';
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;
}>;
}>;
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 (
Activity Heatmap
less
{[
{ 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) => (
))}
more
{/* Day-of-week labels */}
{dayLabels.map((l, i) => (
{l}
))}
{/* Week columns */}
{weeks.map((week, wi) => (
{week.map((date, di) => {
if (!date) return
;
const idx = days.indexOf(date);
const score = idx >= 0 ? scores[idx] : 0;
return (
);
})}
))}
);
}
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 (
{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 (
{m.label}
{m.value}
{m.sub && {m.sub}}
{/* Peer percentile arc indicator */}
{peerPct}% of top
{/* Trend pill below bubble */}
{hasTrend && (
{trendLabel}
)}
);
})}
);
}
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 (
Microsoft Graph not configured
Add MSGRAPH_CLIENT_ID,{' '}
MSGRAPH_CLIENT_SECRET, and{' '}
MSGRAPH_TENANT_ID to your environment to enable engagement data.
);
}
function SummaryCard({
title,
value,
sub,
icon: Icon,
}: {
title: string;
value: string | number;
sub?: string;
icon: React.ElementType;
}) {
return (
{title}
{value}
{sub && {sub}
}
);
}
export default function EngagementPage() {
const [period, setPeriod] = useState<'D7' | 'D30' | 'D90'>('D7');
const [activeTab, setActiveTab] = useState<'overview' | 'by-employee'>('by-employee');
const [summary, setSummary] = useState(null);
const [users, setUsers] = useState([]);
const [pagination, setPagination] = useState(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(null);
const [userDetail, setUserDetail] = useState(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 (
{[...Array(4)].map((_, i) => )}
);
}
return (
{/* Header */}
Employee Engagement
Staff activity across Autotask, Microsoft Teams, and Email
{summary?.lastSynced && (
Last sync: {timeAgo(summary.lastSynced)}
)}
{summary && !summary.configured &&
}
{/* Period selector */}
{(['D7', 'D30', 'D90'] as const).map(p => (
))}
setActiveTab(v as 'overview' | 'by-employee')} className="space-y-6">
Overview
By Employee
{/* ── Overview Tab ── */}
{/* Summary cards */}
{/* Charts */}
Top 10 — Billable Hours
{topByHours.length === 0 ? (
No data yet — run a sync first
) : (
)}
Top 7 — Teams Activity
{topByTeams.length === 0 ? (
No data yet — run a sync first
) : (
)}
{/* ── By Employee Tab ── */}
{usersLoading ? (
{[...Array(8)].map((_, i) => )}
) : users.length === 0 ? (
No engagement data yet — run a sync first
) : (
<>
handleSort('display_name')}
>
Name {sortField === 'display_name' && (sortOrder === 'desc' ? '↓' : '↑')}
Title
handleSort('hours_worked')}
>
Hours {sortField === 'hours_worked' && (sortOrder === 'desc' ? '↓' : '↑')}
handleSort('billable_hours')}
>
Billable {sortField === 'billable_hours' && (sortOrder === 'desc' ? '↓' : '↑')}
handleSort('teams_meetings_attended')}
>
Meetings {sortField === 'teams_meetings_attended' && (sortOrder === 'desc' ? '↓' : '↑')}
Ext. Mtgs
Mtg Hrs
handleSort('teams_chat_messages')}
>
Messages {sortField === 'teams_chat_messages' && (sortOrder === 'desc' ? '↓' : '↑')}
handleSort('emails_sent')}
>
Emails {sortField === 'emails_sent' && (sortOrder === 'desc' ? '↓' : '↑')}
Last Active
Zoom Calls
Zoom Mtgs
{users.map(user => (
handleUserClick(user.graphUserId)}
>
{user.displayName}
{user.email}
{user.jobTitle || '—'}
{user.hoursWorked.toFixed(1)}
{user.billableHours > 0 ? (
{user.billableHours.toFixed(1)}
) : (
0.0
)}
{user.meetingsAttended}
{user.meetingsWithExternal > 0 ? (
{user.meetingsWithExternal}
) : (
—
)}
{user.audioDurationSeconds > 0
? (user.audioDurationSeconds / 3600).toFixed(1) + 'h'
: —}
{user.teamsMessages}
{user.emailsSent}
{user.lastActivity
? new Date(user.lastActivity).toLocaleDateString()
: '—'}
{user.zoomCallCount > 0 ? (
{user.zoomCallCount}
{user.zoomClientCallCount > 0 && (
({user.zoomClientCallCount}c)
)}
) : (
—
)}
{user.zoomMeetingCount > 0 ? (
{user.zoomMeetingCount}
{user.zoomClientMeetingCount > 0 && (
({user.zoomClientMeetingCount}c)
)}
) : (
—
)}
))}
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.total)} of {pagination.total}
)}
>
)}
{/* User detail modal */}
);
}