- 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
1300 lines
57 KiB
TypeScript
1300 lines
57 KiB
TypeScript
'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>
|
||
);
|
||
}
|