'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 */} { setSelectedUser(null); setUserDetail(null); setZoomDetailView('summary'); setTeamsDetailView('summary'); }}> {detailLoading ? 'Loading…' : userDetail?.user.displayName ?? 'User Detail'}
{detailLoading && (
{[...Array(4)].map((_, i) => )}
)} {!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 (
{/* Compact user info + key stats row */}

Email

{userDetail.user.email}

Title

{userDetail.user.jobTitle || '—'}

Department

{userDetail.user.department || '—'}

{/* Activity bubble visualization */}

{periodLabel} — Activity vs. Team

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: `${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 && (

Last activity: {new Date(snap.last_activity_date).toLocaleDateString()}

)}
{/* Activity heatmap */} {userDetail.dailyActivity?.length > 0 && ( )} {/* 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 (

After Hours Activity (5:30 PM – 7:00 AM ET)

{isHigh && ( High after-hours )}

Teams Messages

= highThreshold ? 'text-amber-600 dark:text-amber-400' : ''}`}> {ah.messages} {ah.messagesPct > 0 && ( {ah.messagesPct}% of total )}
{ah.messages === 0 && (

No after-hours messages — or Chat.Read.All permission not granted

)}

Meetings

= highThreshold ? 'text-amber-600 dark:text-amber-400' : ''}`}> {ah.meetings} {ah.meetingsPct > 0 && ( {ah.meetingsPct}% of total )}
); })()} {/* Teams meetings */} {userDetail.recentTeamsMeetings !== undefined && (

Teams Meetings ({periodLabel})

{userDetail.recentTeamsMeetings.length > 0 && ( {userDetail.recentTeamsMeetings.length} shown {userDetail.recentTeamsMeetings.filter(m => m.hasClientAttendees).length > 0 && ( · {userDetail.recentTeamsMeetings.filter(m => m.hasClientAttendees).length} with clients )} )}
{userDetail.recentTeamsMeetings.length > 0 ? (
{[...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 (
{mtg.subject || 'Untitled'} {mtg.hasClientAttendees && ( client )}
{d.toLocaleDateString()} {d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {mtg.durationMinutes != null && ` · ${mtg.durationMinutes}m`}
{mtg.clientCompanies.length > 0 && (

{mtg.clientCompanies.join(', ')}

)} {mtg.matchedEntries?.length > 0 && (
{mtg.matchedEntries.map((te, j) => (
{Number(te.hours_worked).toFixed(1)}h {te.billable && billable} {te.company_name && {te.company_name}} {(te.notes || te.title) && ( {(te.notes || te.title || '').split('\n')[0]} )}
))}
)}
); })}
) : (

No meetings found in the last {periodLabel}

)}
)} {/* Zoom Activity */} {userDetail.zoom && (userDetail.zoom.calls[pKey].total > 0 || userDetail.zoom.meetings[pKey].total > 0) && (

Zoom Activity

{(['summary', 'calls', 'meetings'] as const).map(v => ( ))}
{/* Summary view */} {zoomDetailView === 'summary' && (() => { const calls = userDetail.zoom!.calls[pKey]; const meetings = userDetail.zoom!.meetings[pKey]; return (
{calls.total} calls
{calls.client > 0 && (
{calls.client} client calls
)} {calls.durationSeconds > 0 && (
{(calls.durationSeconds / 3600).toFixed(1)}h on calls
)}
{meetings.withClients > 0 && (
{meetings.withClients} with clients
)}
{calls.outbound}↑ {calls.inbound}↓
); })()} {/* Calls detail view */} {zoomDetailView === 'calls' && (
{userDetail.zoom.recentCalls.length === 0 ? (

No calls in the last 90 days

) : userDetail.zoom.recentCalls.map((call, i) => (
{call.direction === 'outbound' ? '↑' : '↓'}
{call.otherPartyName || call.otherPartyNumber || 'Unknown'} {call.companyName && ( · {call.companyName} )}
{formatDuration(call.durationSeconds)} {new Date(call.startTime).toLocaleDateString()}
))}
)} {/* Meetings detail view */} {zoomDetailView === 'meetings' && (
{userDetail.zoom.recentMeetings.length === 0 ? (

No meetings in the last {periodLabel}

) : [...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 (
{mtg.topic || 'Untitled meeting'} {mtg.hasClientAttendees && ( client )}
{d.toLocaleDateString()} {d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {mtg.durationMinutes != null && ` · ${mtg.durationMinutes}m`}
{mtg.clientCompanies.length > 0 && (

{mtg.clientCompanies.join(', ')}

)} {mtg.externalParticipantNames.length > 0 && (

{mtg.externalParticipantNames.slice(0, 4).join(', ')} {mtg.externalParticipantNames.length > 4 && ` +${mtg.externalParticipantNames.length - 4} more`}

)} {mtg.matchedEntries?.length > 0 && (
{mtg.matchedEntries.map((te, j) => (
{Number(te.hours_worked).toFixed(1)}h {te.billable && billable} {te.company_name && {te.company_name}} {(te.notes || te.title) && ( {(te.notes || te.title || '').split('\n')[0]} )}
))}
)}
); })}
)}
)}
); })()}
); }