From c518eefdb2e49efcc4bdf732a09362a1866941db Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 11 Mar 2026 09:34:51 -0400 Subject: [PATCH] feat: Morning NOC Summary adaptive card for Teams - Add MorningSummaryService with Zabbix aggregation and adaptive card builder - Add webhook delivery system with Teams incoming webhooks - Add admin UI at /admin/morning-summary for webhook/config management - Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history - Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM) - Add outages_only filter (Unavailable triggers only) - Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts - Fix resolved events: event.get value:1 scoped to window with r_eventid filter - Remove emojis from fact rows and section headers in card - Remove Open Zabbix button (duplicate of View Problems) - Add migrations: morning_summary_config + morning_summaries tables - Add outages_only column to morning_summary_config --- app/admin/morning-summary/page.tsx | 467 ++++++ app/admin/zabbix-wan/page.tsx | 202 +++ app/api/data/contracts/[id]/services/route.ts | 85 ++ app/api/engagement/backfill-meetings/route.ts | 186 +++ app/api/engagement/summary/route.ts | 151 ++ app/api/engagement/sync/route.ts | 30 + .../engagement/user/[userId]/history/route.ts | 227 +++ app/api/engagement/user/[userId]/route.ts | 581 ++++++++ app/api/engagement/users/route.ts | 191 +++ .../morning-summary/config/route.ts | 25 + .../morning-summary/history/route.ts | 14 + .../morning-summary/send/route.ts | 23 + .../morning-summary/test/route.ts | 21 + .../morning-summary/webhooks/[id]/route.ts | 33 + .../morning-summary/webhooks/route.ts | 31 + app/api/veeam/compliance/route.ts | 93 +- app/api/veeam/contract-coverage/route.ts | 181 +++ app/api/zabbix/create-host/route.ts | 126 ++ app/api/zabbix/hosts/[hostid]/route.ts | 142 ++ app/api/zabbix/hosts/route.ts | 68 + app/api/zabbix/public-ip/route.ts | 21 + app/api/zabbix/sync-wan/route.ts | 162 +- app/api/zoom/sync/route.ts | 46 + app/backup-status/page.tsx | 165 ++- app/engagement/page.tsx | 1297 +++++++++++++++++ app/engagement/profile/page.tsx | 648 ++++++++ app/globals.css | 24 +- components/backup/compliance-detail-table.tsx | 257 +++- components/backup/contract-coverage-table.tsx | 362 +++++ components/navigation/app-navigation.tsx | 39 +- components/zabbix/host-manager.tsx | 713 +++++++++ dev/pulse-morning-summary-architecture.md | 435 ++++++ dev/windsurf-zabbix-development-guide.md | 774 ++++++++++ lib/services/engagement-sync-service.ts | 424 ++++++ lib/services/entity-sync.ts | 19 + lib/services/morning-summary-service.ts | 543 +++++++ lib/services/msgraph-client.ts | 372 +++++ lib/services/msgraph-factory.ts | 42 + lib/services/sync-scheduler.ts | 82 +- lib/services/zabbix-client.ts | 115 ++ lib/services/zabbix-wan-utils.ts | 206 +++ lib/services/zoom-client.ts | 264 ++++ lib/services/zoom-factory.ts | 42 + lib/services/zoom-sync-service.ts | 387 +++++ lib/types/autotask.ts | 61 +- lib/types/sync.ts | 4 + lib/types/zabbix.ts | 29 +- lib/utils/entity-mapper.ts | 90 +- lib/utils/sync-helpers.ts | 28 +- .../040_create_contract_services_table.sql | 36 + migrations/041_create_engagement_tables.sql | 37 + .../042_add_engagement_calendar_columns.sql | 6 + .../043_add_hours_to_bill_to_time_entries.sql | 5 + .../044_add_contract_id_to_time_entries.sql | 4 + migrations/045_create_zoom_tables.sql | 58 + .../046_create_teams_meetings_table.sql | 28 + migrations/047_add_after_hours_messages.sql | 6 + .../048_create_morning_summary_tables.sql | 45 + package-lock.json | 380 ++++- package.json | 1 + tasks/prd-morning-summary-teams.md | 339 +++++ 61 files changed, 11236 insertions(+), 237 deletions(-) create mode 100644 app/admin/morning-summary/page.tsx create mode 100644 app/api/data/contracts/[id]/services/route.ts create mode 100644 app/api/engagement/backfill-meetings/route.ts create mode 100644 app/api/engagement/summary/route.ts create mode 100644 app/api/engagement/sync/route.ts create mode 100644 app/api/engagement/user/[userId]/history/route.ts create mode 100644 app/api/engagement/user/[userId]/route.ts create mode 100644 app/api/engagement/users/route.ts create mode 100644 app/api/notifications/morning-summary/config/route.ts create mode 100644 app/api/notifications/morning-summary/history/route.ts create mode 100644 app/api/notifications/morning-summary/send/route.ts create mode 100644 app/api/notifications/morning-summary/test/route.ts create mode 100644 app/api/notifications/morning-summary/webhooks/[id]/route.ts create mode 100644 app/api/notifications/morning-summary/webhooks/route.ts create mode 100644 app/api/veeam/contract-coverage/route.ts create mode 100644 app/api/zabbix/create-host/route.ts create mode 100644 app/api/zabbix/hosts/[hostid]/route.ts create mode 100644 app/api/zabbix/hosts/route.ts create mode 100644 app/api/zabbix/public-ip/route.ts create mode 100644 app/api/zoom/sync/route.ts create mode 100644 app/engagement/page.tsx create mode 100644 app/engagement/profile/page.tsx create mode 100644 components/backup/contract-coverage-table.tsx create mode 100644 components/zabbix/host-manager.tsx create mode 100644 dev/pulse-morning-summary-architecture.md create mode 100644 dev/windsurf-zabbix-development-guide.md create mode 100644 lib/services/engagement-sync-service.ts create mode 100644 lib/services/morning-summary-service.ts create mode 100644 lib/services/msgraph-client.ts create mode 100644 lib/services/msgraph-factory.ts create mode 100644 lib/services/zabbix-wan-utils.ts create mode 100644 lib/services/zoom-client.ts create mode 100644 lib/services/zoom-factory.ts create mode 100644 lib/services/zoom-sync-service.ts create mode 100644 migrations/040_create_contract_services_table.sql create mode 100644 migrations/041_create_engagement_tables.sql create mode 100644 migrations/042_add_engagement_calendar_columns.sql create mode 100644 migrations/043_add_hours_to_bill_to_time_entries.sql create mode 100644 migrations/044_add_contract_id_to_time_entries.sql create mode 100644 migrations/045_create_zoom_tables.sql create mode 100644 migrations/046_create_teams_meetings_table.sql create mode 100644 migrations/047_add_after_hours_messages.sql create mode 100644 migrations/048_create_morning_summary_tables.sql create mode 100644 tasks/prd-morning-summary-teams.md diff --git a/app/admin/morning-summary/page.tsx b/app/admin/morning-summary/page.tsx new file mode 100644 index 0000000..b077285 --- /dev/null +++ b/app/admin/morning-summary/page.tsx @@ -0,0 +1,467 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Send, RefreshCw, Trash2, Plus, CheckCircle2, XCircle, + AlertTriangle, Clock, Loader2, ChevronDown, ChevronUp, ToggleLeft, ToggleRight, +} from 'lucide-react'; + +interface WebhookConfig { + id: number; + label: string; + webhook_url: string; + enabled: boolean; + last_delivered_at: string | null; + last_status: string | null; + created_at: string; +} + +interface SummaryConfig { + weekend_suppression: boolean; + monday_extended_window: boolean; + severity_filter: number; + outages_only: boolean; +} + +interface SummaryRow { + id: number; + generated_at: string; + open_count: number; + resolved_count: number; + mttr_minutes: number | null; + is_weekend_window: boolean; + delivery_status: Record; + card_payload?: object; + window_from?: string; + window_to?: string; + clients_affected?: string[]; +} + +function fmtDate(d: string | null) { + if (!d) return 'Never'; + const date = new Date(d); + const diff = Date.now() - date.getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'Just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +function StatusBadge({ status }: { status: string | null }) { + if (!status) return Never sent; + if (status === 'success') return ( + + Success + + ); + return ( + + Failed + + ); +} + +export default function MorningSummaryPage() { + const [webhooks, setWebhooks] = useState([]); + const [config, setConfig] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [testingId, setTestingId] = useState(null); + const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null); + const [cardExpanded, setCardExpanded] = useState(false); + const [showAddWebhook, setShowAddWebhook] = useState(false); + const [newLabel, setNewLabel] = useState(''); + const [newUrl, setNewUrl] = useState(''); + const [addingWebhook, setAddingWebhook] = useState(false); + + const showToast = (msg: string, ok: boolean) => { + setToast({ msg, ok }); + setTimeout(() => setToast(null), 4000); + }; + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const [wRes, cRes, hRes] = await Promise.all([ + fetch('/api/notifications/morning-summary/webhooks'), + fetch('/api/notifications/morning-summary/config'), + fetch('/api/notifications/morning-summary/history'), + ]); + const [wData, cData, hData] = await Promise.all([wRes.json(), cRes.json(), hRes.json()]); + setWebhooks(wData.webhooks ?? []); + setConfig(cData.config ?? null); + setHistory(hData.history ?? []); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchAll(); }, [fetchAll]); + + const handleSendAll = async () => { + setSending(true); + try { + const res = await fetch('/api/notifications/morning-summary/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + const ok = data.results?.filter((r: any) => r.success).length ?? 0; + const fail = data.results?.filter((r: any) => !r.success).length ?? 0; + showToast(`Sent — ${ok} succeeded, ${fail} failed`, fail === 0); + fetchAll(); + } catch (e) { + showToast(String(e), false); + } finally { + setSending(false); + } + }; + + const handleTest = async (webhookId: number, label: string) => { + setTestingId(webhookId); + try { + const res = await fetch('/api/notifications/morning-summary/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ webhookId }), + }); + const data = await res.json(); + showToast(data.result?.success ? `✅ Test sent to ${label}` : `❌ Test failed: ${data.result?.error ?? data.error}`, data.result?.success); + fetchAll(); + } catch (e) { + showToast(String(e), false); + } finally { + setTestingId(null); + } + }; + + const handleToggleWebhook = async (webhook: WebhookConfig) => { + try { + await fetch(`/api/notifications/morning-summary/webhooks/${webhook.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: !webhook.enabled }), + }); + setWebhooks(prev => prev.map(w => w.id === webhook.id ? { ...w, enabled: !w.enabled } : w)); + } catch (e) { + showToast(String(e), false); + } + }; + + const handleDeleteWebhook = async (id: number) => { + if (!confirm('Delete this webhook?')) return; + try { + await fetch(`/api/notifications/morning-summary/webhooks/${id}`, { method: 'DELETE' }); + setWebhooks(prev => prev.filter(w => w.id !== id)); + } catch (e) { + showToast(String(e), false); + } + }; + + const handleAddWebhook = async () => { + if (!newLabel.trim() || !newUrl.trim()) return; + setAddingWebhook(true); + try { + const res = await fetch('/api/notifications/morning-summary/webhooks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: newLabel.trim(), webhook_url: newUrl.trim() }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setWebhooks(prev => [...prev, data.webhook]); + setNewLabel(''); + setNewUrl(''); + setShowAddWebhook(false); + showToast('Webhook added', true); + } catch (e) { + showToast(String(e), false); + } finally { + setAddingWebhook(false); + } + }; + + const handleConfigToggle = async (field: keyof SummaryConfig) => { + if (!config) return; + const updated = { ...config, [field]: !config[field] }; + setConfig(updated); + try { + await fetch('/api/notifications/morning-summary/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [field]: updated[field] }), + }); + } catch (e) { + showToast(String(e), false); + setConfig(config); + } + }; + + const latestSummary = history[0] ?? null; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Toast */} + {toast && ( +
+ {toast.ok ? : } + {toast.msg} +
+ )} + + {/* Header */} +
+
+

☀️ Morning NOC Summary

+

Scheduled 6:30 AM Mon–Fri · Posts to Teams channels via webhook

+
+
+ + +
+
+ + {/* Last Run Stats */} + {latestSummary && ( +
+
+

Last Run

+ + {fmtDate(latestSummary.generated_at)} + {latestSummary.is_weekend_window && Weekend} + +
+
+
+
0 ? 'text-red-400' : 'text-muted-foreground'}`}>{latestSummary.open_count}
+
Open
+
+
+
0 ? 'text-green-400' : 'text-muted-foreground'}`}>{latestSummary.resolved_count}
+
Resolved
+
+
+
{latestSummary.mttr_minutes != null ? `${latestSummary.mttr_minutes}m` : '—'}
+
Avg MTTR
+
+
+ {/* Delivery results */} + {Object.keys(latestSummary.delivery_status).length > 0 && ( +
+

Delivery

+ {Object.entries(latestSummary.delivery_status).map(([wid, r]) => { + const webhook = webhooks.find(w => w.id === parseInt(wid)); + return ( +
+ {webhook?.label ?? `Webhook #${wid}`} + {r.success + ? Delivered + : {r.error ?? `HTTP ${r.httpStatus}`} + } +
+ ); + })} +
+ )} + {/* Card preview toggle */} + {latestSummary.card_payload && ( +
+ + {cardExpanded && ( +
+                  {JSON.stringify(latestSummary.card_payload, null, 2)}
+                
+ )} +
+ )} +
+ )} + + {/* Webhooks */} +
+
+

Webhooks

+ +
+ + {showAddWebhook && ( +
+ setNewLabel(e.target.value)} + /> + setNewUrl(e.target.value)} + /> +
+ + +
+
+ )} + + {webhooks.length === 0 && !showAddWebhook && ( +

No webhooks configured. Add one above.

+ )} + +
+ {webhooks.map(webhook => ( +
+
+
+ {webhook.label} + {webhook.enabled + ? Enabled + : Disabled + } +
+
+ + {webhook.last_delivered_at && ( + {fmtDate(webhook.last_delivered_at)} + )} +
+
+
+ + + +
+
+ ))} +
+
+ + {/* Schedule Config */} + {config && ( +
+

Schedule Settings

+
+
+
+

Weekend Suppression

+

Skip Saturday & Sunday (cron already limits to Mon–Fri)

+
+ +
+
+
+

Monday Extended Window

+

On Mondays, extend window to cover the full weekend (Fri 6 PM → Mon 6:30 AM)

+
+ +
+
+
+

Outages Only

+

Only show "Unavailable" problems — filter out slow response and other non-outage alerts

+
+ +
+
+
+ )} + + {/* Run History */} + {history.length > 0 && ( +
+

Recent Runs

+
+ {history.map(row => { + const statusEntries = Object.values(row.delivery_status); + const allOk = statusEntries.length > 0 && statusEntries.every((r: any) => r.success); + const anyFail = statusEntries.some((r: any) => !r.success); + return ( +
+
+ {allOk && } + {anyFail && } + {statusEntries.length === 0 && } + {fmtDate(row.generated_at)} + {row.is_weekend_window && Weekend} +
+
+ 0 ? 'text-red-400' : 'text-muted-foreground'}> + {row.open_count} open + + 0 ? 'text-green-400' : 'text-muted-foreground'}> + {row.resolved_count} resolved + + {row.mttr_minutes != null && ( + {row.mttr_minutes}m MTTR + )} +
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/app/admin/zabbix-wan/page.tsx b/app/admin/zabbix-wan/page.tsx index 8eeeb0d..7931978 100644 --- a/app/admin/zabbix-wan/page.tsx +++ b/app/admin/zabbix-wan/page.tsx @@ -37,8 +37,13 @@ import { Building2, Server, GitFork, + Plus, + ChevronDown, + ChevronRight, + Network, } from 'lucide-react'; import { toast } from 'sonner'; +import { HostManager } from '@/components/zabbix/host-manager'; type SyncMode = 'all' | 'client' | 'site'; @@ -117,12 +122,37 @@ export default function ZabbixWanPage() { const abortRef = useRef(null); const tableBottomRef = useRef(null); + // Manual host creation state + const [manualOpen, setManualOpen] = useState(false); + const [manualIp, setManualIp] = useState(''); + const [manualSiteName, setManualSiteName] = useState(''); + const [manualCompanyId, setManualCompanyId] = useState(''); + const [manualDryRun, setManualDryRun] = useState(true); + const [manualRunning, setManualRunning] = useState(false); + const [manualResult, setManualResult] = useState<{ + action: string; + dryRun: boolean; + siteName: string; + ip: string; + companyName: string | null; + isp: string | null; + asn: string | null; + hostId: string | null; + error?: string; + } | null>(null); + useEffect(() => { fetch('/api/rmm/site-mappings') .then((r) => r.json()) .then((d) => setMappings(d.mappings ?? [])) .catch(() => toast.error('Failed to load site mappings')) .finally(() => setLoadingMappings(false)); + + // Pre-fill manual IP with the user's current public IP (client-side) + fetch('https://ipinfo.io/json') + .then((r) => r.json()) + .then((d) => { if (d.ip) setManualIp(d.ip); }) + .catch(() => { /* ignore */ }); }, []); // Scroll results table as rows stream in @@ -219,6 +249,42 @@ export default function ZabbixWanPage() { setRunning(false); }; + // Manual host creation + const ipv4Valid = /^(\d{1,3}\.){3}\d{1,3}$/.test(manualIp); + const canCreateManual = !manualRunning && manualIp.trim() !== '' && manualSiteName.trim() !== '' && ipv4Valid; + + const handleManualCreate = async () => { + setManualRunning(true); + setManualResult(null); + try { + const resp = await fetch('/api/zabbix/create-host', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ip: manualIp.trim(), + siteName: manualSiteName.trim(), + companyId: manualCompanyId && manualCompanyId !== 'none' ? Number(manualCompanyId) : undefined, + dryRun: manualDryRun, + }), + }); + const data = await resp.json(); + if (!resp.ok) { + setManualResult({ action: 'error', dryRun: manualDryRun, siteName: manualSiteName, ip: manualIp, companyName: null, isp: null, asn: null, hostId: null, error: data.error }); + toast.error(data.error ?? 'Failed to create host'); + } else { + setManualResult(data); + if (data.action === 'created') toast.success(`Host "${data.siteName}" created (id=${data.hostId})`); + else if (data.action === 'updated') toast.success(`Host "${data.siteName}" updated (id=${data.hostId})`); + else if (data.action === 'skipped') toast.info('Dry run — no changes written to Zabbix'); + } + } catch (err) { + setManualResult({ action: 'error', dryRun: manualDryRun, siteName: manualSiteName, ip: manualIp, companyName: null, isp: null, asn: null, hostId: null, error: String(err) }); + toast.error('Request failed: ' + String(err)); + } finally { + setManualRunning(false); + } + }; + return (
{/* Header */} @@ -410,6 +476,142 @@ export default function ZabbixWanPage() { + {/* Manual Host Creation */} + + setManualOpen((v) => !v)} + > +
+
+ {manualOpen ? : } + + Manual Host +
+ Create a Zabbix host from an IP address — for testing or clients not in RMM +
+
+ {manualOpen && ( + +
+ {/* IP Address */} +
+ + setManualIp(e.target.value)} + className={`w-56 font-mono ${manualIp && !ipv4Valid ? 'border-destructive' : ''}`} + /> + {manualIp && !ipv4Valid && ( +

Enter a valid IPv4 address

+ )} +
+ + {/* Site Name */} +
+ + setManualSiteName(e.target.value)} + className="w-80" + /> +

Becomes the Zabbix host display name

+
+
+ + {/* Client selector */} +
+ + +

+ Links the host to an Autotask client with macros, tags, and a client host group +

+
+ + {/* Dry-run + actions */} +
+
+ +
+ +

+ Preview only — no writes to Zabbix +

+
+
+ + +
+ + {/* Manual result */} + {manualResult && ( +
+
+ + {manualResult.siteName} + {manualResult.ip} +
+
+ {manualResult.companyName && Client: {manualResult.companyName}} + {manualResult.isp && ISP: {manualResult.isp}} + {manualResult.asn && {manualResult.asn}} + {manualResult.hostId && Zabbix ID: {manualResult.hostId}} + {manualResult.dryRun && Dry run — no changes written} +
+ {manualResult.error && ( +

{manualResult.error}

+ )} +
+ )} +
+ )} +
+ + {/* Host Manager */} + + {/* Results */} {(results.length > 0 || running || fatalError) && ( diff --git a/app/api/data/contracts/[id]/services/route.ts b/app/api/data/contracts/[id]/services/route.ts new file mode 100644 index 0000000..652326c --- /dev/null +++ b/app/api/data/contracts/[id]/services/route.ts @@ -0,0 +1,85 @@ +/** + * Contract Services Detail API + * GET /api/data/contracts/[id]/services - Returns a contract with all its service lines + */ + +import { NextRequest, NextResponse } from 'next/server'; +import postgresClient from '@/lib/services/postgres-client'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const contractId = parseInt(id); + if (isNaN(contractId)) { + return NextResponse.json({ error: 'Invalid contract ID' }, { status: 400 }); + } + + const contractResult = await postgresClient.query( + `SELECT ct.*, c.company_name + FROM contracts ct + LEFT JOIN companies c ON c.id = ct.company_id + WHERE ct.id = $1 AND ct.is_deleted = false`, + [contractId] + ); + + if (contractResult.rows.length === 0) { + return NextResponse.json({ error: 'Contract not found' }, { status: 404 }); + } + + const contract = contractResult.rows[0]; + + const servicesResult = await postgresClient.query( + `SELECT + cs.id, + cs.service_id, + cs.service_name, + cs.description, + cs.unit_price, + cs.unit_cost, + cs.quantity, + cs.adjusted_price, + cs.period_type, + cs.start_date, + cs.end_date, + s.name AS catalog_name + FROM contract_services cs + LEFT JOIN autotask_services s ON s.id = cs.service_id + WHERE cs.contract_id = $1 AND cs.is_deleted = false + ORDER BY + CASE + WHEN COALESCE(cs.service_name, s.name) ILIKE '%workstation%backup%' + OR COALESCE(cs.service_name, s.name) ILIKE '%w/ backup%' + OR COALESCE(cs.service_name, s.name) ILIKE '%windows server%' + OR COALESCE(cs.service_name, s.name) ILIKE '%server virtual%' + OR COALESCE(cs.service_name, s.name) ILIKE '%server phys%' + OR COALESCE(cs.service_name, s.name) ILIKE '%esxi host%' + THEN 0 + ELSE 1 + END, + COALESCE(cs.service_name, s.name)`, + [contractId] + ); + + const periodLabels: Record = { + 1: 'Monthly', + 2: 'Quarterly', + 3: 'Semi-Annual', + 4: 'Annual', + 5: 'One-Time', + }; + + const services = servicesResult.rows.map((row) => ({ + ...row, + display_name: row.service_name || row.catalog_name || `Service #${row.service_id}`, + period_label: row.period_type ? (periodLabels[row.period_type] ?? `Type ${row.period_type}`) : null, + })); + + return NextResponse.json({ contract, services }); + } catch (error) { + console.error('[CONTRACT-SERVICES-API] Error:', error); + return NextResponse.json({ error: 'Failed to fetch contract services' }, { status: 500 }); + } +} diff --git a/app/api/engagement/backfill-meetings/route.ts b/app/api/engagement/backfill-meetings/route.ts new file mode 100644 index 0000000..8b3b41b --- /dev/null +++ b/app/api/engagement/backfill-meetings/route.ts @@ -0,0 +1,186 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getMsgraphClient } from '@/lib/services/msgraph-factory'; +import { postgresClient } from '@/lib/services/postgres-client'; + +let backfillInProgress = false; +let backfillStatus: { + running: boolean; + started: string | null; + processed: number; + total: number; + currentUser: string | null; + errors: number; + done: boolean; + log: string[]; +} = { running: false, started: null, processed: 0, total: 0, currentUser: null, errors: 0, done: false, log: [] }; + +export async function GET() { + return NextResponse.json(backfillStatus); +} + +export async function POST(request: NextRequest) { + if (backfillInProgress) { + return NextResponse.json({ error: 'Backfill already running' }, { status: 409 }); + } + + const body = await request.json().catch(() => ({})); + const monthsBack = Math.min(Number(body.monthsBack ?? 12), 24); + + backfillInProgress = true; + backfillStatus = { + running: true, + started: new Date().toISOString(), + processed: 0, + total: 0, + currentUser: null, + errors: 0, + done: false, + log: [], + }; + + // Run async — don't await + runBackfill(monthsBack).finally(() => { + backfillInProgress = false; + }); + + return NextResponse.json({ started: true, monthsBack }); +} + +async function runBackfill(monthsBack: number) { + const log = (msg: string) => { + console.log(`[MEETING-BACKFILL] ${msg}`); + backfillStatus.log.push(msg); + if (backfillStatus.log.length > 200) backfillStatus.log.shift(); + }; + + try { + const client = getMsgraphClient(); + + // Fetch internal domains for attendee classification + const orgDomains = await client.getOrganizationDomains(); + const internalDomains = new Set(orgDomains); + log(`Internal domains: ${[...internalDomains].join(', ')}`); + + // Build contact email index for client matching + const contactRows = await postgresClient.query( + `SELECT id, company_id, LOWER(email_address) as e1, + LOWER(email_address2) as e2, LOWER(email_address3) as e3 + FROM contacts WHERE (is_deleted = false OR is_deleted IS NULL)` + ); + const contactEmailIndex = new Map(); + for (const row of contactRows.rows) { + for (const e of [row.e1, row.e2, row.e3]) { + if (e && !contactEmailIndex.has(e)) { + contactEmailIndex.set(e, { contactId: row.id, companyId: row.company_id }); + } + } + } + log(`Contact index: ${contactEmailIndex.size} emails`); + + // Get all active graph users + const usersResult = await postgresClient.query( + `SELECT id, email, display_name FROM graph_users WHERE account_enabled = true ORDER BY display_name` + ); + const users = usersResult.rows; + backfillStatus.total = users.length; + log(`Users to backfill: ${users.length}, going back ${monthsBack} months`); + + const now = new Date(); + // Build date range: from (monthsBack months ago, start of month) to 91 days ago + // (avoid re-syncing data already covered by the regular 90-day sync) + const backfillEnd = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); + const backfillStart = new Date(now.getFullYear(), now.getMonth() - monthsBack, 1); + log(`Date range: ${backfillStart.toISOString().slice(0, 10)} → ${backfillEnd.toISOString().slice(0, 10)}`); + + for (const user of users) { + backfillStatus.currentUser = user.display_name; + try { + const events = await client.getUserCalendarEvents(user.id, backfillStart, backfillEnd); + let inserted = 0; + + for (const event of events) { + if (!event.id) continue; + try { + const startTime = new Date(event.start.dateTime); + const endTime = new Date(event.end.dateTime); + const durationMinutes = Math.max(0, Math.round((endTime.getTime() - startTime.getTime()) / 60000)); + const attendeeCount = event.attendees.length; + + const externalAttendees = event.attendees.filter(a => { + const aEmail = (a.emailAddress?.address ?? '').toLowerCase(); + if (aEmail === user.email.toLowerCase()) return false; + const domain = aEmail.split('@')[1]; + return domain && !internalDomains.has(domain); + }); + + const meetingResult = await postgresClient.query( + `INSERT INTO teams_meetings + (graph_event_id, user_email, subject, start_time, end_time, + duration_minutes, is_online_meeting, attendee_count, synced_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) + ON CONFLICT (user_email, graph_event_id) DO UPDATE SET + subject = EXCLUDED.subject, + start_time = EXCLUDED.start_time, + end_time = EXCLUDED.end_time, + duration_minutes = EXCLUDED.duration_minutes, + is_online_meeting = EXCLUDED.is_online_meeting, + attendee_count = EXCLUDED.attendee_count, + synced_at = NOW() + RETURNING id`, + [event.id, user.email, event.subject, startTime, endTime, + durationMinutes, event.isOnlineMeeting, attendeeCount] + ); + const meetingId = meetingResult.rows[0]?.id; + if (!meetingId) continue; + + await postgresClient.query( + `DELETE FROM teams_meeting_attendees WHERE meeting_id = $1`, + [meetingId] + ); + + let clientCount = 0; + for (const att of externalAttendees) { + const attEmail = (att.emailAddress?.address ?? '').toLowerCase(); + const attName = att.emailAddress?.name ?? null; + const match = attEmail ? contactEmailIndex.get(attEmail) : undefined; + await postgresClient.query( + `INSERT INTO teams_meeting_attendees + (meeting_id, attendee_email, attendee_name, matched_contact_id, matched_company_id) + VALUES ($1, $2, $3, $4, $5)`, + [meetingId, attEmail || null, attName, + match?.contactId ?? null, match?.companyId ?? null] + ); + if (match?.companyId) clientCount++; + } + + await postgresClient.query( + `UPDATE teams_meetings SET client_attendee_count = $1, has_client_attendees = $2 WHERE id = $3`, + [clientCount, clientCount > 0, meetingId] + ); + inserted++; + } catch (evErr) { + const msg = evErr instanceof Error ? evErr.message : String(evErr); + log(` Event error ${event.id}: ${msg}`); + } + } + + log(`${user.display_name}: ${events.length} events, ${inserted} upserted`); + } catch (userErr) { + const msg = userErr instanceof Error ? userErr.message : String(userErr); + log(`${user.display_name}: SKIP — ${msg}`); + backfillStatus.errors++; + } + + backfillStatus.processed++; + } + + log(`Done. ${backfillStatus.processed} users, ${backfillStatus.errors} errors.`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log(`FATAL: ${msg}`); + } finally { + backfillStatus.running = false; + backfillStatus.done = true; + backfillStatus.currentUser = null; + } +} diff --git a/app/api/engagement/summary/route.ts b/app/api/engagement/summary/route.ts new file mode 100644 index 0000000..b3782b1 --- /dev/null +++ b/app/api/engagement/summary/route.ts @@ -0,0 +1,151 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; +import { isMsgraphConfigured } from '@/lib/services/msgraph-factory'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const period = searchParams.get('period') || 'D30'; + + try { + // Get latest snapshot date for this period + const latestResult = await postgresClient.query( + `SELECT MAX(period_end) as latest_date, MAX(synced_at) as synced_at + FROM engagement_snapshots WHERE period_type = $1`, + [period] + ); + + const latestDate = latestResult.rows[0]?.latest_date; + const lastSynced = latestResult.rows[0]?.synced_at; + + if (!latestDate) { + return NextResponse.json({ + totalStaff: 0, + activeThisPeriod: 0, + avgHoursWorked: 0, + avgBillableHours: 0, + avgTeamsMeetings: 0, + avgEmailsSent: 0, + lastSynced: null, + configured: isMsgraphConfigured(), + }); + } + + // Interval map + const intervalMap: Record = { + D7: '7 days', + D30: '30 days', + D90: '90 days', + }; + const interval = intervalMap[period] || '30 days'; + + // Exclude service/automation accounts: those with a snapshot showing zero inbound + // across all channels (pure outbound senders like Autotask relay accounts) + const notAutomatedFilter = `NOT ( + es.user_email IS NOT NULL + AND COALESCE(es.emails_received, 0) = 0 + AND COALESCE(es.teams_chat_messages, 0) = 0 + AND COALESCE(es.teams_meetings_attended, 0) = 0 + AND COALESCE(es.teams_calls, 0) = 0 + )`; + + // Staff count: human accounts (exclude pure-outbound service accounts) + const staffResult = await postgresClient.query( + `SELECT COUNT(*) as count + FROM graph_users gu + JOIN ( + SELECT DISTINCT ON (LOWER(email)) id, email + FROM resources + WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL + ORDER BY LOWER(email), id + ) r ON LOWER(r.email) = LOWER(gu.email) + LEFT JOIN engagement_snapshots es + ON LOWER(es.user_email) = LOWER(gu.email) + AND es.period_type = $1 AND es.period_end = $2 + WHERE gu.account_enabled = true + AND LOWER(gu.email) LIKE '%@wulfconsulting.%' + AND LOWER(gu.email) NOT LIKE '%#ext#%' + AND ${notAutomatedFilter}`, + [period, latestDate] + ); + const totalStaff = parseInt(staffResult.rows[0]?.count ?? '0'); + + // Active users (had any Teams or email activity) + const activeResult = await postgresClient.query( + `SELECT COUNT(DISTINCT es.user_email) as count + FROM engagement_snapshots es + JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email) + JOIN ( + SELECT DISTINCT ON (LOWER(email)) id, email + FROM resources + WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL + ORDER BY LOWER(email), id + ) r ON LOWER(r.email) = LOWER(gu.email) + WHERE es.period_type = $1 AND es.period_end = $2 + AND (es.teams_meetings_attended > 0 OR es.teams_chat_messages > 0 OR es.emails_sent > 0) + AND gu.account_enabled = true + AND LOWER(gu.email) LIKE '%@wulfconsulting.%' + AND LOWER(gu.email) NOT LIKE '%#ext#%' + AND ${notAutomatedFilter}`, + [period, latestDate] + ); + const activeThisPeriod = parseInt(activeResult.rows[0]?.count ?? '0'); + + // Avg Teams meetings and emails (excluding automated senders) + const avgResult = await postgresClient.query( + `SELECT + AVG(es.teams_meetings_attended) as avg_meetings, + AVG(es.emails_sent) as avg_emails_sent + FROM engagement_snapshots es + JOIN graph_users gu ON LOWER(gu.email) = LOWER(es.user_email) + JOIN ( + SELECT DISTINCT ON (LOWER(email)) id, email + FROM resources + WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL + ORDER BY LOWER(email), id + ) r ON LOWER(r.email) = LOWER(gu.email) + WHERE es.period_type = $1 AND es.period_end = $2 + AND gu.account_enabled = true + AND LOWER(gu.email) LIKE '%@wulfconsulting.%' + AND LOWER(gu.email) NOT LIKE '%#ext#%' + AND ${notAutomatedFilter}`, + [period, latestDate] + ); + + // Avg hours from Autotask time entries joined via resources + const hoursResult = await postgresClient.query( + `SELECT + AVG(resource_hours.total_hours) as avg_hours, + AVG(resource_hours.billable_hours) as avg_billable + FROM ( + SELECT + r.email, + COALESCE(SUM(te.hours_worked), 0) as total_hours, + COALESCE(SUM(CASE WHEN COALESCE(te.billable, true) = true THEN te.hours_worked ELSE 0 END), 0) as billable_hours + FROM graph_users gu + JOIN resources r ON LOWER(r.email) = LOWER(gu.email) + AND (r.is_deleted = false OR r.is_deleted IS NULL) + LEFT JOIN time_entries te ON te.resource_id = r.id + AND te.entry_date >= NOW() - INTERVAL '${interval}' + AND (te.is_deleted = false OR te.is_deleted IS NULL) + WHERE gu.account_enabled = true + AND LOWER(gu.email) LIKE '%@wulfconsulting.%' + AND LOWER(gu.email) NOT LIKE '%#ext#%' + GROUP BY r.email + ) resource_hours` + ); + + return NextResponse.json({ + totalStaff, + activeThisPeriod, + avgHoursWorked: parseFloat(hoursResult.rows[0]?.avg_hours ?? '0').toFixed(1), + avgBillableHours: parseFloat(hoursResult.rows[0]?.avg_billable ?? '0').toFixed(1), + avgTeamsMeetings: parseFloat(avgResult.rows[0]?.avg_meetings ?? '0').toFixed(1), + avgEmailsSent: parseFloat(avgResult.rows[0]?.avg_emails_sent ?? '0').toFixed(0), + lastSynced, + configured: isMsgraphConfigured(), + }); + } catch (error) { + console.error('[ENGAGEMENT-SUMMARY] Error:', error); + return NextResponse.json({ error: 'Failed to fetch engagement summary' }, { status: 500 }); + } +} diff --git a/app/api/engagement/sync/route.ts b/app/api/engagement/sync/route.ts new file mode 100644 index 0000000..207d160 --- /dev/null +++ b/app/api/engagement/sync/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { getEngagementSyncService } from '@/lib/services/engagement-sync-service'; +import { isMsgraphConfigured } from '@/lib/services/msgraph-factory'; + +export async function POST() { + if (!isMsgraphConfigured()) { + return NextResponse.json( + { error: 'Microsoft Graph not configured. Set MSGRAPH_CLIENT_ID, MSGRAPH_CLIENT_SECRET, MSGRAPH_TENANT_ID.' }, + { status: 503 } + ); + } + + const service = getEngagementSyncService(); + + if (service.isSyncInProgress()) { + return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 }); + } + + // Fire and forget + service.sync().catch(err => { + console.error('[ENGAGEMENT-SYNC-API] Background sync failed:', err); + }); + + return NextResponse.json({ message: 'Engagement sync started' }); +} + +export async function GET() { + const service = getEngagementSyncService(); + return NextResponse.json({ isSyncing: service.isSyncInProgress() }); +} diff --git a/app/api/engagement/user/[userId]/history/route.ts b/app/api/engagement/user/[userId]/history/route.ts new file mode 100644 index 0000000..b4c2209 --- /dev/null +++ b/app/api/engagement/user/[userId]/history/route.ts @@ -0,0 +1,227 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; +import { isZoomConfigured } from '@/lib/services/zoom-factory'; + +interface MonthData { + month: string; + hoursWorked: number; + billableHours: number; + daysWorked: number; + teamsMessages: number; + teamsPrivateMessages: number; + teamsCalls: number; + meetingsAttended: number; + meetingsOrganized: number; + emailsSent: number; + emailsReceived: number; + totalMeetings: number; + clientMeetings: number; + meetingDurationMinutes: number; + zoomCalls: number; + zoomClientCalls: number; +} + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ userId: string }> } +) { + const { userId } = await params; + + try { + const userResult = await postgresClient.query( + `SELECT gu.*, + (SELECT r2.id FROM resources r2 + WHERE LOWER(r2.email) = LOWER(gu.email) + AND (r2.is_deleted = false OR r2.is_deleted IS NULL) + ORDER BY (SELECT MAX(te.entry_date) FROM time_entries te WHERE te.resource_id = r2.id AND (te.is_deleted = false OR te.is_deleted IS NULL)) DESC NULLS LAST + LIMIT 1) as autotask_resource_id + FROM graph_users gu + WHERE gu.id = $1`, + [userId] + ); + + if (userResult.rows.length === 0) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + const user = userResult.rows[0]; + + // Daily time entries for the past 365 days + const dailyResult = user.autotask_resource_id + ? await postgresClient.query( + `SELECT + TO_CHAR(entry_date, 'YYYY-MM-DD') as date, + SUM(hours_worked) as hours_worked, + SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_hours + FROM time_entries + WHERE resource_id = $1 + AND (is_deleted = false OR is_deleted IS NULL) + AND entry_date >= NOW() - INTERVAL '365 days' + GROUP BY TO_CHAR(entry_date, 'YYYY-MM-DD') + ORDER BY date`, + [user.autotask_resource_id] + ) + : null; + + // Monthly time entries for the past 12 months + const monthlyHoursResult = user.autotask_resource_id + ? await postgresClient.query( + `SELECT + TO_CHAR(DATE_TRUNC('month', entry_date), 'YYYY-MM') as month, + SUM(hours_worked) as hours_worked, + SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_hours, + COUNT(DISTINCT TO_CHAR(entry_date, 'YYYY-MM-DD')) as days_worked + FROM time_entries + WHERE resource_id = $1 + AND (is_deleted = false OR is_deleted IS NULL) + AND entry_date >= DATE_TRUNC('month', NOW() - INTERVAL '11 months') + GROUP BY DATE_TRUNC('month', entry_date) + ORDER BY month`, + [user.autotask_resource_id] + ) + : null; + + // Monthly engagement snapshots — latest D30 per calendar month + const monthlySnapshotsResult = await postgresClient.query( + `SELECT DISTINCT ON (TO_CHAR(period_end, 'YYYY-MM')) + TO_CHAR(period_end, 'YYYY-MM') as month, + teams_chat_messages, + teams_private_messages, + teams_calls, + teams_meetings_attended, + teams_meetings_organized, + emails_sent, + emails_received + FROM engagement_snapshots + WHERE LOWER(user_email) = LOWER($1) + AND period_type = 'D30' + AND period_end >= NOW() - INTERVAL '13 months' + ORDER BY TO_CHAR(period_end, 'YYYY-MM'), period_end DESC`, + [user.email] + ); + + // Monthly Teams meetings + let monthlyMeetingsResult = null; + try { + monthlyMeetingsResult = await postgresClient.query( + `SELECT + TO_CHAR(DATE_TRUNC('month', start_time), 'YYYY-MM') as month, + COUNT(*) as total_meetings, + SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as client_meetings, + SUM(COALESCE(duration_minutes, 0)) as total_duration_minutes + FROM teams_meetings + WHERE LOWER(user_email) = LOWER($1) + AND start_time >= DATE_TRUNC('month', NOW() - INTERVAL '11 months') + GROUP BY DATE_TRUNC('month', start_time) + ORDER BY month`, + [user.email] + ); + } catch { /* table may not exist */ } + + // Monthly Zoom calls + let monthlyZoomResult = null; + if (isZoomConfigured()) { + try { + monthlyZoomResult = await postgresClient.query( + `SELECT + TO_CHAR(DATE_TRUNC('month', start_time), 'YYYY-MM') as month, + COUNT(*) as call_count, + SUM(CASE WHEN matched_company_id IS NOT NULL THEN 1 ELSE 0 END) as client_calls + FROM zoom_calls + WHERE LOWER(resource_email) = LOWER($1) + AND call_status = 'completed' + AND COALESCE(duration_seconds, 0) > 0 + AND start_time >= DATE_TRUNC('month', NOW() - INTERVAL '11 months') + GROUP BY DATE_TRUNC('month', start_time) + ORDER BY month`, + [user.email] + ); + } catch { /* table may not exist */ } + } + + // Build a complete 12-month map + const now = new Date(); + const monthMap = new Map(); + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + monthMap.set(key, { + month: key, + hoursWorked: 0, + billableHours: 0, + daysWorked: 0, + teamsMessages: 0, + teamsPrivateMessages: 0, + teamsCalls: 0, + meetingsAttended: 0, + meetingsOrganized: 0, + emailsSent: 0, + emailsReceived: 0, + totalMeetings: 0, + clientMeetings: 0, + meetingDurationMinutes: 0, + zoomCalls: 0, + zoomClientCalls: 0, + }); + } + + for (const row of monthlyHoursResult?.rows ?? []) { + const m = monthMap.get(row.month); + if (m) { + m.hoursWorked = parseFloat(row.hours_worked ?? 0); + m.billableHours = parseFloat(row.billable_hours ?? 0); + m.daysWorked = parseInt(row.days_worked ?? 0); + } + } + + for (const row of monthlySnapshotsResult.rows) { + const m = monthMap.get(row.month); + if (m) { + m.teamsMessages = parseInt(row.teams_chat_messages ?? 0); + m.teamsPrivateMessages = parseInt(row.teams_private_messages ?? 0); + m.teamsCalls = parseInt(row.teams_calls ?? 0); + m.meetingsAttended = parseInt(row.teams_meetings_attended ?? 0); + m.meetingsOrganized = parseInt(row.teams_meetings_organized ?? 0); + m.emailsSent = parseInt(row.emails_sent ?? 0); + m.emailsReceived = parseInt(row.emails_received ?? 0); + } + } + + for (const row of monthlyMeetingsResult?.rows ?? []) { + const m = monthMap.get(row.month); + if (m) { + m.totalMeetings = parseInt(row.total_meetings ?? 0); + m.clientMeetings = parseInt(row.client_meetings ?? 0); + m.meetingDurationMinutes = parseInt(row.total_duration_minutes ?? 0); + } + } + + for (const row of monthlyZoomResult?.rows ?? []) { + const m = monthMap.get(row.month); + if (m) { + m.zoomCalls = parseInt(row.call_count ?? 0); + m.zoomClientCalls = parseInt(row.client_calls ?? 0); + } + } + + return NextResponse.json({ + user: { + id: user.id, + displayName: user.display_name, + email: user.email, + jobTitle: user.job_title, + department: user.department, + autotaskResourceId: user.autotask_resource_id, + }, + daily: (dailyResult?.rows ?? []).map(r => ({ + date: r.date, + hoursWorked: parseFloat(r.hours_worked ?? 0), + billableHours: parseFloat(r.billable_hours ?? 0), + })), + monthly: Array.from(monthMap.values()), + }); + } catch (error) { + console.error('[ENGAGEMENT-HISTORY] Error:', error); + return NextResponse.json({ error: 'Failed to fetch history' }, { status: 500 }); + } +} diff --git a/app/api/engagement/user/[userId]/route.ts b/app/api/engagement/user/[userId]/route.ts new file mode 100644 index 0000000..e424169 --- /dev/null +++ b/app/api/engagement/user/[userId]/route.ts @@ -0,0 +1,581 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; +import { isZoomConfigured } from '@/lib/services/zoom-factory'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ userId: string }> } +) { + const { userId } = await params; + const periodParam = (request.nextUrl.searchParams.get('period') ?? 'D30').toUpperCase(); + const periodDays = periodParam === 'D7' ? 7 : periodParam === 'D90' ? 90 : 30; + + try { + // Get user from graph_users + const userResult = await postgresClient.query( + `SELECT gu.*, + (SELECT r2.id FROM resources r2 + WHERE LOWER(r2.email) = LOWER(gu.email) + AND (r2.is_deleted = false OR r2.is_deleted IS NULL) + ORDER BY (SELECT MAX(te.entry_date) FROM time_entries te WHERE te.resource_id = r2.id AND (te.is_deleted = false OR te.is_deleted IS NULL)) DESC NULLS LAST + LIMIT 1) as autotask_resource_id + FROM graph_users gu + WHERE gu.id = $1`, + [userId] + ); + + if (userResult.rows.length === 0) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + const user = userResult.rows[0]; + + // Get all snapshots for this user across periods + const snapshotsResult = await postgresClient.query( + `SELECT * + FROM engagement_snapshots + WHERE LOWER(user_email) = LOWER($1) + ORDER BY period_end DESC, period_type`, + [user.email] + ); + + // Get Autotask hours per period + const hoursResult = user.autotask_resource_id + ? await postgresClient.query( + `SELECT + SUM(CASE WHEN entry_date >= NOW() - INTERVAL '7 days' THEN hours_worked ELSE 0 END) as hours_d7, + SUM(CASE WHEN entry_date >= NOW() - INTERVAL '30 days' THEN hours_worked ELSE 0 END) as hours_d30, + SUM(CASE WHEN entry_date >= NOW() - INTERVAL '90 days' THEN hours_worked ELSE 0 END) as hours_d90, + SUM(CASE WHEN entry_date >= NOW() - INTERVAL '7 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d7, + SUM(CASE WHEN entry_date >= NOW() - INTERVAL '30 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d30, + SUM(CASE WHEN entry_date >= NOW() - INTERVAL '90 days' AND COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as billable_d90 + FROM time_entries + WHERE resource_id = $1 + AND (is_deleted = false OR is_deleted IS NULL) + AND COALESCE(type, 0) NOT IN (15, 16) + AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209)`, + [user.autotask_resource_id] + ) + : null; + + const hours = hoursResult?.rows[0]; + + // Recent time entries + const recentEntriesResult = user.autotask_resource_id + ? await postgresClient.query( + `SELECT te.entry_date, te.hours_worked, te.billable, te.notes, te.title, + te.start_date_time, te.end_date_time, + COALESCE(c.company_name, tc.company_name) as company_name + FROM time_entries te + LEFT JOIN companies c ON c.id = te.company_id + LEFT JOIN tickets t ON t.id = te.ticket_id + LEFT JOIN companies tc ON tc.id = t.company_id + WHERE te.resource_id = $1 + AND (te.is_deleted = false OR te.is_deleted IS NULL) + AND te.entry_date >= NOW() - ($2 || ' days')::INTERVAL + AND COALESCE(te.type, 0) NOT IN (15, 16) + AND COALESCE(te.allocation_code_id, 0) NOT IN (91206, 91209) + ORDER BY te.entry_date DESC + LIMIT 500`, + [user.autotask_resource_id, periodDays] + ) + : null; + + // Teams meeting detail records (client-attended) + let recentTeamsMeetings: Array<{ + subject: string | null; + startTime: string; + durationMinutes: number | null; + attendeeCount: number; + clientAttendeeCount: number; + hasClientAttendees: boolean; + clientCompanies: string[]; + participantNames: string[]; + }> = []; + try { + const teamsMeetingsResult = await postgresClient.query( + `SELECT tm.subject, tm.start_time, tm.duration_minutes, + tm.attendee_count, tm.client_attendee_count, tm.has_client_attendees, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT co.company_name), NULL) AS client_companies, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT COALESCE(tma.attendee_name, tma.attendee_email)), NULL) AS participant_names + FROM teams_meetings tm + LEFT JOIN teams_meeting_attendees tma ON tma.meeting_id = tm.id + LEFT JOIN companies co ON co.id = tma.matched_company_id + WHERE LOWER(tm.user_email) = LOWER($1) + AND tm.start_time >= NOW() - ($2 || ' days')::INTERVAL + GROUP BY tm.id + ORDER BY tm.start_time DESC + LIMIT 200`, + [user.email, periodDays] + ); + recentTeamsMeetings = teamsMeetingsResult.rows.map(r => ({ + subject: r.subject, + startTime: r.start_time, + durationMinutes: r.duration_minutes, + attendeeCount: r.attendee_count, + clientAttendeeCount: r.client_attendee_count, + hasClientAttendees: r.has_client_attendees, + clientCompanies: r.client_companies ?? [], + participantNames: r.participant_names ?? [], + })); + } catch { + // teams_meetings table may not exist yet + } + + // Peer max benchmarks — highest value across all active employees for this period + const peerMaxResult = await postgresClient.query( + `SELECT + MAX(h.hours_total) as max_hours, + MAX(h.hours_billable) as max_billable_hours, + MAX(m.meeting_count) as max_meetings, + MAX(m.client_meetings)as max_client_meetings, + MAX(s.messages) as max_messages, + MAX(s.emails) as max_emails, + MAX(zc.calls) as max_calls + FROM ( + SELECT resource_id, + SUM(hours_worked) as hours_total, + SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as hours_billable + FROM time_entries + WHERE (is_deleted = false OR is_deleted IS NULL) + AND entry_date >= NOW() - ($1 || ' days')::INTERVAL + AND COALESCE(type, 0) NOT IN (15, 16) + AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209) + GROUP BY resource_id + ) h + CROSS JOIN ( + SELECT user_email, + COUNT(*) as meeting_count, + SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as client_meetings + FROM teams_meetings + WHERE start_time >= NOW() - ($1 || ' days')::INTERVAL + GROUP BY user_email + ) m + CROSS JOIN ( + SELECT user_email, + MAX(teams_chat_messages + teams_private_messages) as messages, + MAX(emails_sent) as emails + FROM engagement_snapshots + WHERE period_type = $2 + GROUP BY user_email + ) s + CROSS JOIN ( + SELECT resource_email, + COUNT(*) as calls + FROM zoom_calls + WHERE call_status = 'completed' + AND start_time >= NOW() - ($1 || ' days')::INTERVAL + GROUP BY resource_email + ) zc`, + [periodDays, periodParam] + ).catch(() => null); + + // Previous period values for trend calculation + const prevPeriodResult = user.autotask_resource_id + ? await postgresClient.query( + `SELECT + SUM(hours_worked) as prev_hours, + SUM(CASE WHEN COALESCE(billable, true) = true THEN hours_worked ELSE 0 END) as prev_billable + FROM time_entries + WHERE resource_id = $1 + AND (is_deleted = false OR is_deleted IS NULL) + AND entry_date >= NOW() - ($2 || ' days')::INTERVAL * 2 + AND entry_date < NOW() - ($2 || ' days')::INTERVAL + AND COALESCE(type, 0) NOT IN (15, 16) + AND COALESCE(allocation_code_id, 0) NOT IN (91206, 91209)`, + [user.autotask_resource_id, periodDays] + ).catch(() => null) + : null; + + const prevMeetingsResult = await postgresClient.query( + `SELECT COUNT(*) as prev_meetings, + SUM(CASE WHEN has_client_attendees THEN 1 ELSE 0 END) as prev_client_meetings + FROM teams_meetings + WHERE LOWER(user_email) = LOWER($1) + AND start_time >= NOW() - ($2 || ' days')::INTERVAL * 2 + AND start_time < NOW() - ($2 || ' days')::INTERVAL`, + [user.email, periodDays] + ).catch(() => null); + + const prevZoomResult = await postgresClient.query( + `SELECT COUNT(*) as prev_calls + FROM zoom_calls + WHERE LOWER(resource_email) = LOWER($1) + AND call_status = 'completed' + AND start_time >= NOW() - ($2 || ' days')::INTERVAL * 2 + AND start_time < NOW() - ($2 || ' days')::INTERVAL`, + [user.email, periodDays] + ).catch(() => null); + + // After-hours meetings (5:30 PM – 7:00 AM America/New_York) + const afterHoursMeetingsResult = await postgresClient.query( + `SELECT COUNT(*) as count + FROM teams_meetings + WHERE LOWER(user_email) = LOWER($1) + AND start_time >= NOW() - ($2 || ' days')::INTERVAL + AND ( + EXTRACT(HOUR FROM start_time AT TIME ZONE 'America/New_York') * 60 + + EXTRACT(MINUTE FROM start_time AT TIME ZONE 'America/New_York') >= 1050 + OR + EXTRACT(HOUR FROM start_time AT TIME ZONE 'America/New_York') * 60 + + EXTRACT(MINUTE FROM start_time AT TIME ZONE 'America/New_York') < 420 + )`, + [user.email, periodDays] + ).catch(() => ({ rows: [{ count: 0 }] })); + const afterHoursMeetings = parseInt(afterHoursMeetingsResult.rows[0]?.count ?? 0); + + // Daily activity heatmap data + let dailyActivity: Array<{ date: string; meetings: number; zoomCalls: number; hours: number; meetingMins: number }> = []; + try { + const dailyResult = await postgresClient.query( + `SELECT + day::date as date, + COALESCE(SUM(meetings), 0)::int as meetings, + COALESCE(SUM(zoom_calls), 0)::int as zoom_calls, + COALESCE(SUM(hours), 0)::float as hours, + COALESCE(SUM(meeting_mins), 0)::int as meeting_mins + FROM ( + SELECT DATE(start_time) as day, COUNT(*) as meetings, SUM(duration_minutes) as meeting_mins, 0 as zoom_calls, 0 as hours + FROM teams_meetings + WHERE LOWER(user_email) = LOWER($1) + AND start_time >= NOW() - ($2 || ' days')::INTERVAL + GROUP BY DATE(start_time) + UNION ALL + SELECT DATE(start_time) as day, 0, 0, COUNT(*) as zoom_calls, 0 + FROM zoom_calls + WHERE LOWER(resource_email) = LOWER($1) + AND call_status = 'completed' + AND start_time >= NOW() - ($2 || ' days')::INTERVAL + GROUP BY DATE(start_time) + UNION ALL + SELECT DATE(entry_date) as day, 0, 0, 0, SUM(hours_worked) as hours + FROM time_entries te + WHERE te.resource_id = $3 + AND (te.is_deleted = false OR te.is_deleted IS NULL) + AND entry_date >= NOW() - ($2 || ' days')::INTERVAL + AND COALESCE(te.type, 0) NOT IN (15, 16) + AND COALESCE(te.allocation_code_id, 0) NOT IN (91206, 91209) + GROUP BY DATE(entry_date) + ) combined + GROUP BY day + ORDER BY day`, + [user.email, periodDays, user.autotask_resource_id] + ); + dailyActivity = dailyResult.rows.map(r => ({ + date: r.date instanceof Date ? r.date.toISOString().slice(0, 10) : String(r.date).slice(0, 10), + meetings: Number(r.meetings), + zoomCalls: Number(r.zoom_calls), + hours: Number(r.hours), + meetingMins: Number(r.meeting_mins), + })); + } catch { + // ignore if tables missing + } + + // Zoom data (only if configured and tables exist) + let zoomData = null; + if (isZoomConfigured()) { + try { + const email = user.email; + + const [zoomCallsResult, zoomMeetingsResult, zoomTopClientsResult, recentCallsResult, recentMeetingsResult] = await Promise.all([ + postgresClient.query( + `SELECT + SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN 1 ELSE 0 END) as calls_d7, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN 1 ELSE 0 END) as calls_d30, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN 1 ELSE 0 END) as calls_d90, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d7, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d30, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND (matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) THEN 1 ELSE 0 END) as client_calls_d90, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d7, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d30, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND direction = 'outbound' THEN 1 ELSE 0 END) as outbound_d90, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d7, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d30, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND direction = 'inbound' THEN 1 ELSE 0 END) as inbound_d90, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d7, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d30, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN COALESCE(duration_seconds, 0) ELSE 0 END) as duration_d90 + FROM zoom_calls + WHERE LOWER(resource_email) = LOWER($1) + AND call_status = 'completed' + AND COALESCE(duration_seconds, 0) > 0`, + [email] + ), + postgresClient.query( + `SELECT + SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' THEN 1 ELSE 0 END) as meetings_d7, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' THEN 1 ELSE 0 END) as meetings_d30, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' THEN 1 ELSE 0 END) as meetings_d90, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '7 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d7, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '30 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d30, + SUM(CASE WHEN start_time >= NOW() - INTERVAL '90 days' AND has_client_attendees = true THEN 1 ELSE 0 END) as client_meetings_d90 + FROM zoom_meetings + WHERE LOWER(host_email) = LOWER($1)`, + [email] + ), + postgresClient.query( + `SELECT + co.company_name, + COUNT(DISTINCT zc.id) as call_count, + COUNT(DISTINCT zm.id) as meeting_count + FROM companies co + LEFT JOIN zoom_calls zc + ON zc.matched_company_id = co.id + AND LOWER(zc.resource_email) = LOWER($1) + AND zc.start_time >= NOW() - INTERVAL '30 days' + LEFT JOIN zoom_meetings zm + ON zm.id IN ( + SELECT zmp.meeting_id FROM zoom_meeting_participants zmp + WHERE zmp.matched_company_id = co.id + ) + AND LOWER(zm.host_email) = LOWER($1) + AND zm.start_time >= NOW() - INTERVAL '30 days' + WHERE (zc.id IS NOT NULL OR zm.id IS NOT NULL) + GROUP BY co.id, co.company_name + ORDER BY (COUNT(DISTINCT zc.id) + COUNT(DISTINCT zm.id)) DESC + LIMIT 5`, + [email] + ), + postgresClient.query( + `SELECT zc.direction, zc.call_status, zc.other_party_name, zc.other_party_number, + zc.start_time, zc.duration_seconds, + co.company_name + FROM zoom_calls zc + LEFT JOIN companies co ON co.id = zc.matched_company_id + WHERE LOWER(zc.resource_email) = LOWER($1) + AND zc.call_status = 'completed' + AND COALESCE(zc.duration_seconds, 0) > 0 + ORDER BY zc.start_time DESC + LIMIT 30`, + [email] + ), + postgresClient.query( + `SELECT zm.id, zm.topic, zm.start_time, zm.end_time, zm.duration_minutes, + zm.participant_count, zm.client_participant_count, zm.has_client_attendees, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT CASE WHEN NOT zmp.is_internal AND zmp.participant_name IS NOT NULL THEN zmp.participant_name ELSE NULL END), NULL) AS external_participant_names, + ARRAY_REMOVE(ARRAY_AGG(DISTINCT co.company_name), NULL) AS client_companies + FROM zoom_meetings zm + LEFT JOIN zoom_meeting_participants zmp ON zmp.meeting_id = zm.id + LEFT JOIN companies co ON co.id = zmp.matched_company_id + WHERE (LOWER(zm.host_email) = LOWER($1) + OR EXISTS (SELECT 1 FROM zoom_meeting_participants p WHERE p.meeting_id = zm.id AND LOWER(p.participant_email) = LOWER($1))) + AND zm.start_time >= NOW() - ($2 || ' days')::INTERVAL + GROUP BY zm.id + ORDER BY zm.start_time DESC + LIMIT 100`, + [email, periodDays] + ), + ]); + + const cr = zoomCallsResult.rows[0]; + const mr = zoomMeetingsResult.rows[0]; + + zoomData = { + calls: { + d7: { + total: parseInt(cr.calls_d7 ?? 0), + client: parseInt(cr.client_calls_d7 ?? 0), + outbound: parseInt(cr.outbound_d7 ?? 0), + inbound: parseInt(cr.inbound_d7 ?? 0), + durationSeconds: parseInt(cr.duration_d7 ?? 0), + }, + d30: { + total: parseInt(cr.calls_d30 ?? 0), + client: parseInt(cr.client_calls_d30 ?? 0), + outbound: parseInt(cr.outbound_d30 ?? 0), + inbound: parseInt(cr.inbound_d30 ?? 0), + durationSeconds: parseInt(cr.duration_d30 ?? 0), + }, + d90: { + total: parseInt(cr.calls_d90 ?? 0), + client: parseInt(cr.client_calls_d90 ?? 0), + outbound: parseInt(cr.outbound_d90 ?? 0), + inbound: parseInt(cr.inbound_d90 ?? 0), + durationSeconds: parseInt(cr.duration_d90 ?? 0), + }, + }, + meetings: { + d7: { total: parseInt(mr.meetings_d7 ?? 0), withClients: parseInt(mr.client_meetings_d7 ?? 0) }, + d30: { total: parseInt(mr.meetings_d30 ?? 0), withClients: parseInt(mr.client_meetings_d30 ?? 0) }, + d90: { total: parseInt(mr.meetings_d90 ?? 0), withClients: parseInt(mr.client_meetings_d90 ?? 0) }, + }, + topClients: zoomTopClientsResult.rows.map(r => ({ + companyName: r.company_name, + callCount: parseInt(r.call_count), + meetingCount: parseInt(r.meeting_count), + })), + recentCalls: recentCallsResult.rows.map(r => ({ + direction: r.direction, + status: r.call_status, + otherPartyName: r.other_party_name, + otherPartyNumber: r.other_party_number, + startTime: r.start_time, + durationSeconds: r.duration_seconds, + companyName: r.company_name, + })), + recentMeetings: recentMeetingsResult.rows.map(r => { + const zmStart = new Date(r.start_time).getTime(); + const zmEnd = r.end_time + ? new Date(r.end_time).getTime() + : r.duration_minutes + ? zmStart + r.duration_minutes * 60_000 + : zmStart + 60 * 60_000; + const zmClientNames: string[] = (r.client_companies ?? []).map((n: string) => n.toLowerCase()); + const toMs = (ts: string | null): number | null => { + if (!ts) return null; + const s = ts.toString(); + const normalized = /[Z+\-]\d*$/.test(s.trim()) ? s : s.trim() + 'Z'; + return new Date(normalized).getTime(); + }; + const matched = (recentEntriesResult?.rows ?? []).filter(te => { + if (r.has_client_attendees && zmClientNames.length > 0) { + if (!te.company_name) return false; + const teCo = te.company_name.toLowerCase(); + if (!zmClientNames.some((c: string) => teCo.includes(c) || c.includes(teCo))) return false; + } + if (te.start_date_time) { + const teStart = toMs(te.start_date_time)!; + const teEnd = te.end_date_time + ? toMs(te.end_date_time)! + : teStart + te.hours_worked * 3_600_000; + const tolerance = 30 * 60_000; + return teStart < zmEnd + tolerance && teEnd > zmStart - tolerance; + } + const teDate = new Date((toMs(te.entry_date) ?? 0)).toUTCString().slice(0, 16); + const zmDate = new Date(r.start_time).toUTCString().slice(0, 16); + return teDate === zmDate; + }); + return { + topic: r.topic, + startTime: r.start_time, + endTime: r.end_time, + durationMinutes: r.duration_minutes, + participantCount: r.participant_count, + clientParticipantCount: r.client_participant_count, + hasClientAttendees: r.has_client_attendees, + externalParticipantNames: r.external_participant_names ?? [], + clientCompanies: r.client_companies ?? [], + matchedEntries: matched.map(te => ({ + hours_worked: te.hours_worked, + billable: te.billable, + notes: te.notes, + title: te.title, + company_name: te.company_name, + start_date_time: te.start_date_time, + end_date_time: te.end_date_time, + })), + }; + }), + }; + } catch { + // Zoom tables may not exist yet — return null + zoomData = null; + } + } + + // After-hours summary (messages from snapshot for current period, meetings from DB) + const currentSnap = snapshotsResult.rows.find(s => s.period_type === periodParam); + const totalMessages = (currentSnap?.teams_chat_messages ?? 0) + (currentSnap?.teams_private_messages ?? 0); + const totalMeetings = recentTeamsMeetings.length; + const afterHoursMessages = currentSnap?.after_hours_messages ?? 0; + + return NextResponse.json({ + user: { + id: user.id, + displayName: user.display_name, + email: user.email, + jobTitle: user.job_title, + department: user.department, + accountEnabled: user.account_enabled, + autotaskResourceId: user.autotask_resource_id, + }, + afterHours: { + messages: afterHoursMessages, + meetings: afterHoursMeetings, + messagesPct: totalMessages > 0 ? Math.round((afterHoursMessages / totalMessages) * 100) : 0, + meetingsPct: totalMeetings > 0 ? Math.round((afterHoursMeetings / totalMeetings) * 100) : 0, + }, + snapshots: snapshotsResult.rows, + hours: hours + ? { + d7: { total: parseFloat(hours.hours_d7 ?? 0), billable: parseFloat(hours.billable_d7 ?? 0) }, + d30: { total: parseFloat(hours.hours_d30 ?? 0), billable: parseFloat(hours.billable_d30 ?? 0) }, + d90: { total: parseFloat(hours.hours_d90 ?? 0), billable: parseFloat(hours.billable_d90 ?? 0) }, + } + : null, + recentEntries: recentEntriesResult?.rows ?? [], + recentTeamsMeetings: recentTeamsMeetings.map(mtg => { + const mtgStart = new Date(mtg.startTime).getTime(); + const mtgEnd = mtg.durationMinutes + ? mtgStart + mtg.durationMinutes * 60_000 + : mtgStart + 60 * 60_000; // assume 1h if unknown + const mtgClientNames = mtg.clientCompanies.map((n: string) => n.toLowerCase()); + // Normalize a DB timestamp to ms — treat naive timestamps as UTC + const toMs = (ts: string | null): number | null => { + if (!ts) return null; + const s = ts.toString(); + // If no timezone info, append Z so Date parses it as UTC + const normalized = /[Z+\-]\d*$/.test(s.trim()) ? s : s.trim() + 'Z'; + return new Date(normalized).getTime(); + }; + const matched = (recentEntriesResult?.rows ?? []).filter(te => { + // Company match: if the meeting has client companies, the time entry must be for one of them + if (mtg.hasClientAttendees && mtgClientNames.length > 0) { + if (!te.company_name) return false; + const teCo = te.company_name.toLowerCase(); + if (!mtgClientNames.some((c: string) => teCo.includes(c) || c.includes(teCo))) return false; + } + // Time match + if (te.start_date_time) { + const teStart = toMs(te.start_date_time)!; + const teEnd = te.end_date_time + ? toMs(te.end_date_time)! + : teStart + te.hours_worked * 3_600_000; + const tolerance = 30 * 60_000; + return teStart < mtgEnd + tolerance && teEnd > mtgStart - tolerance; + } + // Fallback: same UTC date + const teDate = new Date((toMs(te.entry_date) ?? 0)).toUTCString().slice(0, 16); + const mtgDate = new Date(mtg.startTime).toUTCString().slice(0, 16); + return teDate === mtgDate; + }); + return { ...mtg, matchedEntries: matched.map(te => ({ + hours_worked: te.hours_worked, + billable: te.billable, + notes: te.notes, + title: te.title, + company_name: te.company_name, + start_date_time: te.start_date_time, + end_date_time: te.end_date_time, + })) }; + }), + meetingCounts: { + total: recentTeamsMeetings.length, + withClients: recentTeamsMeetings.filter(m => m.hasClientAttendees).length, + }, + dailyActivity, + zoom: zoomData, + peerMax: peerMaxResult?.rows[0] + ? { + hours: parseFloat(peerMaxResult.rows[0].max_hours ?? 0), + billableHours:parseFloat(peerMaxResult.rows[0].max_billable_hours ?? 0), + meetings: parseInt(peerMaxResult.rows[0].max_meetings ?? 0), + clientMeetings:parseInt(peerMaxResult.rows[0].max_client_meetings ?? 0), + messages: parseInt(peerMaxResult.rows[0].max_messages ?? 0), + emails: parseInt(peerMaxResult.rows[0].max_emails ?? 0), + calls: parseInt(peerMaxResult.rows[0].max_calls ?? 0), + } + : null, + trend: { + hours: parseFloat(prevPeriodResult?.rows[0]?.prev_hours ?? 0), + billable: parseFloat(prevPeriodResult?.rows[0]?.prev_billable ?? 0), + meetings: parseInt(prevMeetingsResult?.rows[0]?.prev_meetings ?? 0), + calls: parseInt(prevZoomResult?.rows[0]?.prev_calls ?? 0), + }, + }); + } catch (error) { + console.error('[ENGAGEMENT-USER-DETAIL] Error:', error); + return NextResponse.json({ error: 'Failed to fetch user detail' }, { status: 500 }); + } +} diff --git a/app/api/engagement/users/route.ts b/app/api/engagement/users/route.ts new file mode 100644 index 0000000..f86ffb2 --- /dev/null +++ b/app/api/engagement/users/route.ts @@ -0,0 +1,191 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const period = searchParams.get('period') || 'D30'; + const sort = searchParams.get('sort') || 'billable_hours'; + const order = searchParams.get('order') === 'asc' ? 'ASC' : 'DESC'; + const page = Math.max(1, parseInt(searchParams.get('page') || '1')); + const pageSize = 50; + const offset = (page - 1) * pageSize; + + const intervalMap: Record = { + D7: '7 days', + D30: '30 days', + D90: '90 days', + }; + const interval = intervalMap[period] || '30 days'; + + const allowedSorts: Record = { + hours_worked: 'hours_worked', + billable_hours: 'billable_hours', + teams_meetings_attended: 'teams_meetings_attended', + teams_chat_messages: 'teams_chat_messages', + emails_sent: 'emails_sent', + last_activity: 'last_active', + display_name: 'display_name', + zoom_call_count: 'zoom_call_count', + zoom_meeting_count: 'zoom_meeting_count', + }; + const sortCol = allowedSorts[sort] || 'billable_hours'; + + try { + const latestResult = await postgresClient.query( + `SELECT MAX(period_end) as latest_date FROM engagement_snapshots WHERE period_type = $1`, + [period] + ); + const latestDate = latestResult.rows[0]?.latest_date; + + if (!latestDate) { + return NextResponse.json({ users: [], pagination: { total: 0, page, pageSize } }); + } + + const countResult = await postgresClient.query( + `SELECT COUNT(*) as total + FROM graph_users gu + JOIN ( + SELECT DISTINCT ON (LOWER(email)) id, email + FROM resources + WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL + ORDER BY LOWER(email), id + ) r ON LOWER(r.email) = LOWER(gu.email) + LEFT JOIN engagement_snapshots es_cnt + ON LOWER(es_cnt.user_email) = LOWER(gu.email) + AND es_cnt.period_type = $1 AND es_cnt.period_end = $2 + WHERE gu.account_enabled = true + AND LOWER(gu.email) LIKE '%@wulfconsulting.%' + AND LOWER(gu.email) NOT LIKE '%#ext#%' + AND NOT ( + es_cnt.user_email IS NOT NULL + AND COALESCE(es_cnt.emails_received, 0) = 0 + AND COALESCE(es_cnt.teams_chat_messages, 0) = 0 + AND COALESCE(es_cnt.teams_meetings_attended, 0) = 0 + AND COALESCE(es_cnt.teams_calls, 0) = 0 + )`, + [period, latestDate] + ); + const total = parseInt(countResult.rows[0]?.total ?? '0'); + + const usersResult = await postgresClient.query( + `SELECT + gu.id as graph_user_id, + gu.display_name, + gu.email, + gu.job_title, + gu.department, + r.id as autotask_resource_id, + COALESCE(te_agg.total_hours, 0) as hours_worked, + COALESCE(te_agg.billable_hours, 0) as billable_hours, + COALESCE(es.teams_chat_messages, 0) as teams_chat_messages, + COALESCE(es.teams_private_messages, 0) as teams_private_messages, + COALESCE(es.teams_calls, 0) as teams_calls, + COALESCE(es.teams_meetings_attended, 0) as teams_meetings_attended, + COALESCE(es.teams_meetings_organized, 0) as teams_meetings_organized, + COALESCE(es.emails_sent, 0) as emails_sent, + COALESCE(es.emails_received, 0) as emails_received, + COALESCE(es.emails_read, 0) as emails_read, + COALESCE(es.audio_duration_seconds, 0) as audio_duration_seconds, + COALESCE(es.meeting_duration_seconds, 0) as meeting_duration_seconds, + COALESCE(es.meetings_with_external, 0) as meetings_with_external, + LEAST(GREATEST(es.last_activity_date, last_te.entry_date::date), CURRENT_DATE) as last_active, + COALESCE(zc.zoom_call_count, 0) as zoom_call_count, + COALESCE(zc.zoom_client_call_count, 0) as zoom_client_call_count, + COALESCE(zc.zoom_call_duration_seconds, 0) as zoom_call_duration_seconds, + COALESCE(zm.zoom_meeting_count, 0) as zoom_meeting_count, + COALESCE(zm.zoom_client_meeting_count, 0) as zoom_client_meeting_count + FROM graph_users gu + LEFT JOIN engagement_snapshots es + ON LOWER(es.user_email) = LOWER(gu.email) + AND es.period_type = $1 + AND es.period_end = $2 + JOIN ( + SELECT DISTINCT ON (LOWER(email)) * + FROM resources + WHERE (is_deleted = false OR is_deleted IS NULL) AND email IS NOT NULL + ORDER BY LOWER(email), id + ) r ON LOWER(r.email) = LOWER(gu.email) + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(te.hours_worked), 0) as total_hours, + COALESCE(SUM(CASE WHEN COALESCE(te.billable, true) = true THEN te.hours_worked ELSE 0 END), 0) as billable_hours + FROM time_entries te + WHERE te.resource_id = r.id + AND te.entry_date >= NOW() - INTERVAL '${interval}' + AND (te.is_deleted = false OR te.is_deleted IS NULL) + ) te_agg ON true + LEFT JOIN LATERAL ( + SELECT MAX(te2.entry_date) as entry_date + FROM time_entries te2 + WHERE te2.resource_id = r.id + AND (te2.is_deleted = false OR te2.is_deleted IS NULL) + ) last_te ON true + LEFT JOIN ( + SELECT resource_email, + COUNT(*) AS zoom_call_count, + COUNT(*) FILTER (WHERE matched_contact_id IS NOT NULL OR matched_company_id IS NOT NULL) AS zoom_client_call_count, + COALESCE(SUM(duration_seconds), 0) AS zoom_call_duration_seconds + FROM zoom_calls + WHERE start_time >= NOW() - INTERVAL '${interval}' + AND call_status = 'completed' + AND COALESCE(duration_seconds, 0) > 0 + GROUP BY resource_email + ) zc ON LOWER(r.email) = LOWER(zc.resource_email) + LEFT JOIN ( + SELECT host_email, + COUNT(*) AS zoom_meeting_count, + COUNT(*) FILTER (WHERE has_client_attendees = true) AS zoom_client_meeting_count + FROM zoom_meetings + WHERE start_time >= NOW() - INTERVAL '${interval}' + GROUP BY host_email + ) zm ON LOWER(r.email) = LOWER(zm.host_email) + WHERE gu.account_enabled = true + AND LOWER(gu.email) LIKE '%@wulfconsulting.%' + AND LOWER(gu.email) NOT LIKE '%#ext#%' + AND NOT ( + es.user_email IS NOT NULL + AND COALESCE(es.emails_received, 0) = 0 + AND COALESCE(es.teams_chat_messages, 0) = 0 + AND COALESCE(es.teams_meetings_attended, 0) = 0 + AND COALESCE(es.teams_calls, 0) = 0 + ) + ORDER BY ${sortCol} ${order} NULLS LAST + LIMIT $3 OFFSET $4`, + [period, latestDate, pageSize, offset] + ); + + const users = usersResult.rows.map(row => ({ + graphUserId: row.graph_user_id, + displayName: row.display_name, + email: row.email, + jobTitle: row.job_title, + department: row.department, + autotaskResourceId: row.autotask_resource_id, + hoursWorked: parseFloat(row.hours_worked), + billableHours: parseFloat(row.billable_hours), + teamsMessages: parseInt(row.teams_chat_messages) + parseInt(row.teams_private_messages), + teamsCallCount: parseInt(row.teams_calls), + meetingsAttended: parseInt(row.teams_meetings_attended), + meetingsOrganized: parseInt(row.teams_meetings_organized), + emailsSent: parseInt(row.emails_sent), + emailsReceived: parseInt(row.emails_received), + audioDurationSeconds: parseInt(row.audio_duration_seconds), + meetingDurationSeconds: parseInt(row.meeting_duration_seconds), + meetingsWithExternal: parseInt(row.meetings_with_external), + lastActivity: row.last_active, + zoomCallCount: parseInt(row.zoom_call_count), + zoomClientCallCount: parseInt(row.zoom_client_call_count), + zoomCallDurationSeconds: parseInt(row.zoom_call_duration_seconds), + zoomMeetingCount: parseInt(row.zoom_meeting_count), + zoomClientMeetingCount: parseInt(row.zoom_client_meeting_count), + })); + + return NextResponse.json({ + users, + pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) }, + }); + } catch (error) { + console.error('[ENGAGEMENT-USERS] Error:', error); + return NextResponse.json({ error: 'Failed to fetch engagement users' }, { status: 500 }); + } +} diff --git a/app/api/notifications/morning-summary/config/route.ts b/app/api/notifications/morning-summary/config/route.ts new file mode 100644 index 0000000..4b93d87 --- /dev/null +++ b/app/api/notifications/morning-summary/config/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getMorningSummaryService } from '@/lib/services/morning-summary-service'; + +export async function GET() { + try { + const service = getMorningSummaryService(); + const config = await service.getConfig(); + return NextResponse.json({ config }); + } catch (error) { + console.error('[GET /api/notifications/morning-summary/config]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const service = getMorningSummaryService(); + const config = await service.updateConfig(body); + return NextResponse.json({ config }); + } catch (error) { + console.error('[PUT /api/notifications/morning-summary/config]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/notifications/morning-summary/history/route.ts b/app/api/notifications/morning-summary/history/route.ts new file mode 100644 index 0000000..0c29009 --- /dev/null +++ b/app/api/notifications/morning-summary/history/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { getMorningSummaryService } from '@/lib/services/morning-summary-service'; + +export async function GET() { + try { + const service = getMorningSummaryService(); + const history = await service.getSummaryHistory(10); + const latest = await service.getLatestSummaryRow(); + return NextResponse.json({ history, latest }); + } catch (error) { + console.error('[GET /api/notifications/morning-summary/history]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/notifications/morning-summary/send/route.ts b/app/api/notifications/morning-summary/send/route.ts new file mode 100644 index 0000000..feee307 --- /dev/null +++ b/app/api/notifications/morning-summary/send/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getMorningSummaryService } from '@/lib/services/morning-summary-service'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const webhookIds: number[] | undefined = body.webhookIds; + + const service = getMorningSummaryService(); + const { summary, results } = await service.run(webhookIds); + + return NextResponse.json({ + success: true, + openCount: summary.openCount, + resolvedCount: summary.resolvedCount, + isWeekendWindow: summary.isWeekendWindow, + results, + }); + } catch (error) { + console.error('[POST /api/notifications/morning-summary/send]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/notifications/morning-summary/test/route.ts b/app/api/notifications/morning-summary/test/route.ts new file mode 100644 index 0000000..be83073 --- /dev/null +++ b/app/api/notifications/morning-summary/test/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getMorningSummaryService } from '@/lib/services/morning-summary-service'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { webhookId } = body as { webhookId: number }; + + if (!webhookId) { + return NextResponse.json({ error: 'webhookId is required' }, { status: 400 }); + } + + const service = getMorningSummaryService(); + const result = await service.testSend(webhookId); + + return NextResponse.json({ success: result.success, result }); + } catch (error) { + console.error('[POST /api/notifications/morning-summary/test]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/notifications/morning-summary/webhooks/[id]/route.ts b/app/api/notifications/morning-summary/webhooks/[id]/route.ts new file mode 100644 index 0000000..a5a9b3b --- /dev/null +++ b/app/api/notifications/morning-summary/webhooks/[id]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getMorningSummaryService } from '@/lib/services/morning-summary-service'; + +export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id: rawId } = await params; + const id = parseInt(rawId, 10); + if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + + const body = await request.json(); + const service = getMorningSummaryService(); + const webhook = await service.updateWebhook(id, body); + return NextResponse.json({ webhook }); + } catch (error) { + console.error('[PUT /api/notifications/morning-summary/webhooks/:id]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id: rawId } = await params; + const id = parseInt(rawId, 10); + if (isNaN(id)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + + const service = getMorningSummaryService(); + await service.deleteWebhook(id); + return NextResponse.json({ deleted: true }); + } catch (error) { + console.error('[DELETE /api/notifications/morning-summary/webhooks/:id]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/notifications/morning-summary/webhooks/route.ts b/app/api/notifications/morning-summary/webhooks/route.ts new file mode 100644 index 0000000..55f1086 --- /dev/null +++ b/app/api/notifications/morning-summary/webhooks/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getMorningSummaryService } from '@/lib/services/morning-summary-service'; + +export async function GET() { + try { + const service = getMorningSummaryService(); + const webhooks = await service.getWebhooks(); + return NextResponse.json({ webhooks }); + } catch (error) { + console.error('[GET /api/notifications/morning-summary/webhooks]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { label, webhook_url } = body as { label: string; webhook_url: string }; + + if (!label || !webhook_url) { + return NextResponse.json({ error: 'label and webhook_url are required' }, { status: 400 }); + } + + const service = getMorningSummaryService(); + const webhook = await service.createWebhook(label, webhook_url); + return NextResponse.json({ webhook }, { status: 201 }); + } catch (error) { + console.error('[POST /api/notifications/morning-summary/webhooks]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/veeam/compliance/route.ts b/app/api/veeam/compliance/route.ts index b472d3c..1f52155 100644 --- a/app/api/veeam/compliance/route.ts +++ b/app/api/veeam/compliance/route.ts @@ -40,13 +40,102 @@ export async function GET() { 'SELECT MAX(computed_at) as computed_at FROM veeam_compliance_results' ); - // Get mismatch details + // Get mismatch details with active contract service coverage. + // "Covered" = company has an active contract with a ContractService line + // matching workstation backup (service_name ILIKE '%workstation%backup%' or '%w/ backup%'). + // Falls back to billing_items if contract_services not yet populated. const mismatches = await postgresClient.query(` + WITH cs_backup AS ( + SELECT DISTINCT ON (ct.company_id) + ct.company_id, + ct.id AS contract_id, + ct.contract_name, + cs.quantity AS contracted_qty + FROM contract_services cs + JOIN contracts ct ON ct.id = cs.contract_id + WHERE cs.is_deleted = false + AND ct.is_deleted = false + AND ct.status = 1 + AND ( + cs.service_name ILIKE '%workstation%backup%' + OR cs.service_name ILIKE '%w/ backup%' + OR cs.description ILIKE '%workstation%backup%' + OR cs.description ILIKE '%w/ backup%' + OR cs.service_name ILIKE '%windows server%' + OR cs.service_name ILIKE '%server virtual%' + OR cs.service_name ILIKE '%server physical%' + OR cs.service_name ILIKE '%server phys%' + OR cs.service_name ILIKE '%esxi host%' + OR cs.service_name ILIKE '%wulf 365 it complete endpoint%' + OR cs.service_name ILIKE '%wulf 365 it complete server%' + OR cs.service_name ILIKE '%wulf it complete (server)%' + OR cs.service_name ILIKE '%wulf it complete (endpoint)%' + ) + ORDER BY ct.company_id, + CASE + WHEN ct.contract_name ILIKE '%backup%' THEN 0 + WHEN ct.contract_name ILIKE '%managed it%' THEN 1 + WHEN ct.contract_name ILIKE '%fixed price%' THEN 2 + ELSE 3 + END + ), + bi_backup AS ( + SELECT DISTINCT ON (bi.company_id) + bi.company_id, + ct.id AS contract_id, + ct.contract_name, + bi.quantity AS contracted_qty + FROM billing_items bi + LEFT JOIN LATERAL ( + SELECT id, contract_name + FROM contracts + WHERE company_id = bi.company_id + AND is_deleted = false AND status = 1 + ORDER BY + CASE + WHEN contract_name ILIKE '%backup%' THEN 0 + WHEN contract_name ILIKE '%managed it%' THEN 1 + WHEN contract_name ILIKE '%fixed price%' THEN 2 + ELSE 3 + END + LIMIT 1 + ) ct ON true + WHERE bi.is_deleted = false + AND bi.description ILIKE '%windows workstation w/ backup%' + AND bi.synced_at::date = ( + SELECT MAX(synced_at::date) FROM billing_items WHERE is_deleted = false + ) + ORDER BY bi.company_id, bi.quantity DESC + ), + coverage AS ( + SELECT + company_id, + contract_id, + contract_name, + contracted_qty, + 'contract_services' AS source + FROM cs_backup + UNION ALL + SELECT + b.company_id, + b.contract_id, + b.contract_name, + b.contracted_qty, + 'billing_items' AS source + FROM bi_backup b + WHERE NOT EXISTS (SELECT 1 FROM cs_backup cs WHERE cs.company_id = b.company_id) + ) SELECT cr.*, - c.company_name + c.company_name, + cov.contract_name AS billing_contract_name, + cov.contract_id AS billing_contract_id, + (cov.company_id IS NOT NULL) AS billing_covered, + cov.contracted_qty::int AS billing_contracted_qty, + cov.source AS coverage_source FROM veeam_compliance_results cr LEFT JOIN companies c ON c.id = cr.company_id + LEFT JOIN coverage cov ON cov.company_id = cr.company_id ORDER BY cr.mismatch_type, c.company_name, cr.device_name `); diff --git a/app/api/veeam/contract-coverage/route.ts b/app/api/veeam/contract-coverage/route.ts new file mode 100644 index 0000000..e72bb05 --- /dev/null +++ b/app/api/veeam/contract-coverage/route.ts @@ -0,0 +1,181 @@ +/** + * Contract Coverage API + * GET /api/veeam/contract-coverage + * Returns per-company contracted vs deployed counts for Servers, Workstations, M365 + * plus the individual contract service lines for the expanded view. + */ + +import { NextResponse } from 'next/server'; +import postgresClient from '@/lib/services/postgres-client'; + +export async function GET() { + try { + const result = await postgresClient.query(` + WITH + + -- All active contract service lines with category classification + cs_lines AS ( + SELECT + ct.company_id, + ct.id AS contract_id, + ct.contract_name, + cs.id AS cs_id, + cs.service_name AS line_name, + cs.unit_price, + cs.unit_cost, + CASE + WHEN cs.service_name ILIKE '%windows server%' + OR cs.service_name ILIKE '%server virtual%' + OR cs.service_name ILIKE '%server phys%' + OR cs.service_name ILIKE '%esxi host%' + OR cs.service_name ILIKE '%wulf 365 it complete server%' + OR cs.service_name ILIKE '%wulf it complete (server)%' + THEN 'server' + WHEN cs.service_name ILIKE '%workstation%backup%' + OR cs.service_name ILIKE '%w/ backup%' + OR cs.service_name ILIKE '%wulf 365 it complete endpoint%' + OR cs.service_name ILIKE '%wulf it complete (endpoint)%' + THEN 'workstation' + WHEN cs.service_name ILIKE '%microsoft 365%' + OR cs.service_name ILIKE '%office 365%' + OR cs.service_name ILIKE '%exchange online%' + OR cs.service_name ILIKE '%m365%' + OR cs.service_name ILIKE '%veeam backup for microsoft office 365%' + THEN 'm365' + ELSE 'other' + END AS category + FROM contract_services cs + JOIN contracts ct ON ct.id = cs.contract_id + WHERE cs.is_deleted = false + AND ct.is_deleted = false + AND ct.status = 1 + ), + + -- Summarise contracted counts per company + contracted AS ( + SELECT + company_id, + COUNT(*) FILTER (WHERE category = 'server') AS contracted_servers, + COUNT(*) FILTER (WHERE category = 'workstation') AS contracted_workstations, + COUNT(*) FILTER (WHERE category = 'm365') AS contracted_m365, + COUNT(*) FILTER (WHERE category = 'other') AS contracted_other, + COUNT(*) AS contracted_total + FROM cs_lines + GROUP BY company_id + ), + + -- Deployed counts from Veeam agent jobs + deployed AS ( + SELECT + o.company_id, + COUNT(*) FILTER (WHERE j.operation_mode = 'Server') AS deployed_servers, + COUNT(*) FILTER (WHERE j.operation_mode = 'Workstation') AS deployed_workstations, + COUNT(*) FILTER (WHERE j.operation_mode NOT IN ('Server','Workstation')) AS deployed_other + FROM veeam_backup_agent_jobs j + JOIN veeam_organizations o ON o.instance_uid = j.organization_uid + WHERE j.is_enabled = true + GROUP BY o.company_id + ), + + -- All companies that appear in either side + all_companies AS ( + SELECT company_id FROM contracted + UNION + SELECT company_id FROM deployed + ) + + SELECT + ac.company_id, + c.company_name, + COALESCE(ct.contracted_servers, 0) AS contracted_servers, + COALESCE(ct.contracted_workstations, 0) AS contracted_workstations, + COALESCE(ct.contracted_m365, 0) AS contracted_m365, + COALESCE(ct.contracted_other, 0) AS contracted_other, + COALESCE(d.deployed_servers, 0) AS deployed_servers, + COALESCE(d.deployed_workstations, 0) AS deployed_workstations, + COALESCE(d.deployed_other, 0) AS deployed_other + FROM all_companies ac + JOIN companies c ON c.id = ac.company_id + LEFT JOIN contracted ct ON ct.company_id = ac.company_id + LEFT JOIN deployed d ON d.company_id = ac.company_id + ORDER BY c.company_name + `); + + // Build per-company service lines map + const linesResult = await postgresClient.query(` + SELECT + ct.company_id, + ct.id AS contract_id, + ct.contract_name, + cs.id AS cs_id, + cs.service_name AS line_name, + cs.unit_price, + cs.unit_cost, + CASE + WHEN cs.service_name ILIKE '%windows server%' + OR cs.service_name ILIKE '%server virtual%' + OR cs.service_name ILIKE '%server phys%' + OR cs.service_name ILIKE '%esxi host%' + OR cs.service_name ILIKE '%wulf 365 it complete server%' + OR cs.service_name ILIKE '%wulf it complete (server)%' + THEN 'server' + WHEN cs.service_name ILIKE '%workstation%backup%' + OR cs.service_name ILIKE '%w/ backup%' + OR cs.service_name ILIKE '%wulf 365 it complete endpoint%' + OR cs.service_name ILIKE '%wulf it complete (endpoint)%' + THEN 'workstation' + WHEN cs.service_name ILIKE '%microsoft 365%' + OR cs.service_name ILIKE '%office 365%' + OR cs.service_name ILIKE '%exchange online%' + OR cs.service_name ILIKE '%m365%' + OR cs.service_name ILIKE '%veeam backup for microsoft office 365%' + THEN 'm365' + ELSE 'other' + END AS category + FROM contract_services cs + JOIN contracts ct ON ct.id = cs.contract_id + WHERE cs.is_deleted = false + AND ct.is_deleted = false + AND ct.status = 1 + ORDER BY ct.company_id, ct.contract_name, cs.service_name + `); + + // Group lines by company_id + const linesByCompany: Record = {}; + for (const row of linesResult.rows) { + const cid = row.company_id; + if (!linesByCompany[cid]) linesByCompany[cid] = []; + linesByCompany[cid].push(row); + } + + const rows = result.rows.map((r) => ({ + company_id: r.company_id, + company_name: r.company_name, + contracted: { + servers: Number(r.contracted_servers), + workstations: Number(r.contracted_workstations), + m365: Number(r.contracted_m365), + other: Number(r.contracted_other), + }, + deployed: { + servers: Number(r.deployed_servers), + workstations: Number(r.deployed_workstations), + other: Number(r.deployed_other), + }, + lines: (linesByCompany[r.company_id] || []).map((l) => ({ + cs_id: l.cs_id, + contract_id: l.contract_id, + contract_name: l.contract_name, + line_name: l.line_name, + unit_price: l.unit_price != null ? Number(l.unit_price) : null, + unit_cost: l.unit_cost != null ? Number(l.unit_cost) : null, + category: l.category, + })), + })); + + return NextResponse.json({ rows }); + } catch (error) { + console.error('[contract-coverage] error:', error); + return NextResponse.json({ rows: [] }); + } +} diff --git a/app/api/zabbix/create-host/route.ts b/app/api/zabbix/create-host/route.ts new file mode 100644 index 0000000..0108683 --- /dev/null +++ b/app/api/zabbix/create-host/route.ts @@ -0,0 +1,126 @@ +/** + * Manual Zabbix Host Creation API + * POST /api/zabbix/create-host + * + * Creates a Zabbix host with ICMP monitoring using the same logic as the + * RMM discovery flow, but with a user-supplied IP and site name instead + * of auto-discovered WAN IPs. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { ZabbixClient } from '@/lib/services/zabbix-client'; +import { postgresClient } from '@/lib/services/postgres-client'; +import { + lookupIsp, + buildHostParams, + discoverIcmpTemplate, +} from '@/lib/services/zabbix-wan-utils'; + +interface CreateHostBody { + ip: string; + siteName: string; + companyId?: number; + dryRun?: boolean; +} + +export async function POST(request: NextRequest) { + try { + const body: CreateHostBody = await request.json(); + const { ip, siteName, companyId, dryRun = false } = body; + + // Validate required fields + if (!ip || !siteName) { + return NextResponse.json( + { error: 'ip and siteName are required' }, + { status: 400 } + ); + } + + // Basic IPv4 validation + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + if (!ipv4Regex.test(ip)) { + return NextResponse.json( + { error: 'Invalid IPv4 address format' }, + { status: 400 } + ); + } + + if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) { + return NextResponse.json( + { error: 'Zabbix is not configured. Add ZABBIX_API_URL and ZABBIX_API_TOKEN to your environment.' }, + { status: 500 } + ); + } + + // Resolve company name if companyId provided + let companyName: string | undefined; + if (companyId) { + const res = await postgresClient.query<{ company_name: string }>( + 'SELECT company_name FROM companies WHERE id = $1 LIMIT 1', + [companyId] + ); + companyName = res.rows[0]?.company_name; + } + + // ISP lookup + const ispInfo = await lookupIsp(ip); + + // Dry-run: return what would happen without writing to Zabbix + if (dryRun) { + return NextResponse.json({ + action: 'skipped', + dryRun: true, + siteName, + ip, + companyId: companyId ?? null, + companyName: companyName ?? null, + isp: ispInfo?.isp ?? null, + asn: ispInfo?.asn ?? null, + hostId: null, + }); + } + + const zabbix = new ZabbixClient({ + apiUrl: process.env.ZABBIX_API_URL!, + apiToken: process.env.ZABBIX_API_TOKEN!, + }); + + // Ensure host group + discover ICMP template + const globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites'); + const icmpTemplateId = await discoverIcmpTemplate(zabbix); + + // Build host params using the shared utility + const hostParams = await buildHostParams({ + siteName, + wanIp: ip, + companyId, + companyName, + ispInfo, + source: 'manual', + icmpTemplateId, + globalGroupId, + zabbix, + }); + + // Create or update the host + const { action, hostid } = await zabbix.upsertHost(hostParams); + + return NextResponse.json({ + action, + dryRun: false, + siteName, + ip, + companyId: companyId ?? null, + companyName: companyName ?? null, + isp: ispInfo?.isp ?? null, + asn: ispInfo?.asn ?? null, + hostId: hostid, + }); + } catch (error) { + console.error('[create-host] error:', error); + return NextResponse.json( + { error: String(error) }, + { status: 500 } + ); + } +} diff --git a/app/api/zabbix/hosts/[hostid]/route.ts b/app/api/zabbix/hosts/[hostid]/route.ts new file mode 100644 index 0000000..de102c1 --- /dev/null +++ b/app/api/zabbix/hosts/[hostid]/route.ts @@ -0,0 +1,142 @@ +/** + * PUT /api/zabbix/hosts/[hostid] — full update of an existing Zabbix host + * + * If companyId is provided (or changed), rebuilds host groups / macros / tags + * via buildHostParams (same as creation flow). Otherwise applies fields directly. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { ZabbixClient } from '@/lib/services/zabbix-client'; +import { postgresClient } from '@/lib/services/postgres-client'; +import { ZabbixHostMacro, ZabbixHostTag } from '@/lib/types/zabbix'; +import { + sanitizeHostname, + lookupIsp, + buildHostParams, + discoverIcmpTemplate, +} from '@/lib/services/zabbix-wan-utils'; + +interface UpdateBody { + name: string; + ip: string; + description?: string; + companyId?: number | null; + tags?: ZabbixHostTag[]; + macros?: ZabbixHostMacro[]; + rebuildFromClient?: boolean; // if true, fully re-run buildHostParams +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ hostid: string }> } +) { + try { + const { hostid } = await params; + const body: UpdateBody = await request.json(); + const { name, ip, description, companyId, tags, macros, rebuildFromClient } = body; + + if (!name || !ip) { + return NextResponse.json({ error: 'name and ip are required' }, { status: 400 }); + } + + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + if (!ipv4Regex.test(ip)) { + return NextResponse.json({ error: 'Invalid IPv4 address' }, { status: 400 }); + } + + if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) { + return NextResponse.json({ error: 'Zabbix not configured' }, { status: 500 }); + } + + const zabbix = new ZabbixClient({ + apiUrl: process.env.ZABBIX_API_URL!, + apiToken: process.env.ZABBIX_API_TOKEN!, + }); + + if (rebuildFromClient) { + // Full rebuild: re-resolve ISP, rebuild groups/macros/tags from scratch + let companyName: string | undefined; + if (companyId) { + const res = await postgresClient.query<{ company_name: string }>( + 'SELECT company_name FROM companies WHERE id = $1 LIMIT 1', + [companyId] + ); + companyName = res.rows[0]?.company_name; + } + + const ispInfo = await lookupIsp(ip); + const globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites'); + const icmpTemplateId = await discoverIcmpTemplate(zabbix); + + const hostParams = await buildHostParams({ + siteName: name, + wanIp: ip, + companyId: companyId ?? undefined, + companyName, + ispInfo, + source: 'manual', + icmpTemplateId, + globalGroupId, + zabbix, + }); + + await zabbix['rpc']('host.update', { + hostid, + host: sanitizeHostname(name), + name, + description: description ?? hostParams.description, + groups: hostParams.groups, + templates: hostParams.templates, + macros: hostParams.macros, + tags: hostParams.tags, + }); + + // Update IP interface separately + const existingHost = await zabbix['rpc'] }>>('host.get', { + output: ['hostid'], + hostids: [hostid], + selectInterfaces: ['interfaceid', 'main', 'type'], + }); + const mainIface = existingHost[0]?.interfaces?.find((i: any) => i.main === 1 || i.main === '1'); + if (mainIface) { + await zabbix['rpc']('hostinterface.update', { + interfaceid: mainIface.interfaceid, + ip, + useip: 1, + dns: '', + }); + } + } else { + // Direct update — apply exactly what was sent + await zabbix['rpc']('host.update', { + hostid, + host: sanitizeHostname(name), + name, + ...(description !== undefined ? { description } : {}), + ...(tags !== undefined ? { tags } : {}), + ...(macros !== undefined ? { macros } : {}), + }); + + // Update IP interface + const existingHost = await zabbix['rpc'] }>>('host.get', { + output: ['hostid'], + hostids: [hostid], + selectInterfaces: ['interfaceid', 'main', 'type'], + }); + const mainIface = existingHost[0]?.interfaces?.find((i: any) => i.main === 1 || i.main === '1'); + if (mainIface) { + await zabbix['rpc']('hostinterface.update', { + interfaceid: mainIface.interfaceid, + ip, + useip: 1, + dns: '', + }); + } + } + + return NextResponse.json({ updated: true, hostid }); + } catch (error) { + console.error('[PUT /api/zabbix/hosts/[hostid]]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/zabbix/hosts/route.ts b/app/api/zabbix/hosts/route.ts new file mode 100644 index 0000000..60c6b17 --- /dev/null +++ b/app/api/zabbix/hosts/route.ts @@ -0,0 +1,68 @@ +/** + * GET /api/zabbix/hosts — list all hosts with full detail + RMM match status + * DELETE /api/zabbix/hosts — bulk delete by hostid array + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { ZabbixClient } from '@/lib/services/zabbix-client'; +import { postgresClient } from '@/lib/services/postgres-client'; + +function makeZabbix() { + if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) { + throw new Error('Zabbix is not configured. Set ZABBIX_API_URL and ZABBIX_API_TOKEN.'); + } + return new ZabbixClient({ + apiUrl: process.env.ZABBIX_API_URL!, + apiToken: process.env.ZABBIX_API_TOKEN!, + }); +} + +export async function GET() { + try { + const zabbix = makeZabbix(); + const hosts = await zabbix.getHosts(); + + // Load all known RMM site UIDs from Postgres for mismatch detection + const res = await postgresClient.query<{ rmm_site_uid: string }>( + 'SELECT rmm_site_uid FROM rmm_site_mappings' + ); + const knownSiteUids = new Set(res.rows.map((r) => r.rmm_site_uid)); + + // Annotate each host with rmmMatched flag + const annotated = hosts.map((h) => { + const rmmUidMacro = h.macros?.find((m) => m.macro === '{$RMM_SITE_UID}'); + const sourceTag = h.tags?.find((t) => t.tag === 'source')?.value ?? null; + + let rmmMatched: boolean | null = null; + if (sourceTag === 'datto-rmm') { + rmmMatched = rmmUidMacro ? knownSiteUids.has(rmmUidMacro.value) : false; + } + + return { ...h, rmmMatched, sourceTag }; + }); + + return NextResponse.json({ hosts: annotated }); + } catch (error) { + console.error('[GET /api/zabbix/hosts]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + try { + const body = await request.json(); + const { hostids } = body as { hostids: string[] }; + + if (!Array.isArray(hostids) || hostids.length === 0) { + return NextResponse.json({ error: 'hostids array is required' }, { status: 400 }); + } + + const zabbix = makeZabbix(); + await zabbix.deleteHosts(hostids); + + return NextResponse.json({ deleted: hostids.length }); + } catch (error) { + console.error('[DELETE /api/zabbix/hosts]', error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/zabbix/public-ip/route.ts b/app/api/zabbix/public-ip/route.ts new file mode 100644 index 0000000..cf18555 --- /dev/null +++ b/app/api/zabbix/public-ip/route.ts @@ -0,0 +1,21 @@ +/** + * GET /api/zabbix/public-ip + * Returns the server's current public IP address. + */ + +import { NextResponse } from 'next/server'; + +export async function GET() { + try { + const res = await fetch('https://ipinfo.io/json', { + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`ipinfo ${res.status}`); + const data = await res.json(); + return NextResponse.json({ ip: data.ip ?? null }); + } catch { + return NextResponse.json({ ip: null }); + } +} diff --git a/app/api/zabbix/sync-wan/route.ts b/app/api/zabbix/sync-wan/route.ts index 0583716..b534ef5 100644 --- a/app/api/zabbix/sync-wan/route.ts +++ b/app/api/zabbix/sync-wan/route.ts @@ -3,21 +3,18 @@ import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory'; import { ZabbixClient } from '@/lib/services/zabbix-client'; import { postgresClient } from '@/lib/services/postgres-client'; import { DattoRMMDevice } from '@/lib/types/datto-rmm'; -import { ZabbixHostMacro, ZabbixHostTag } from '@/lib/types/zabbix'; +import { + lookupIsp, + clearIspCache, + buildHostParams, + discoverIcmpTemplate, +} from '@/lib/services/zabbix-wan-utils'; export const maxDuration = 300; type SyncMode = 'all' | 'client' | 'site'; type SiteAction = 'created' | 'updated' | 'filtered' | 'no-ip' | 'error' | 'skipped'; -interface IspInfo { - isp: string; // "Comcast Cable Communications, LLC" - asn: string; // "AS7922" - city: string; - region: string; - country: string; -} - interface WanResolution { ip: string | null; count: number; @@ -151,61 +148,8 @@ function resolveWanIp( return { ip: topIp, count: topCount, multiWan, allIps, singleDeviceFallback }; } -// --------------------------------------------------------------------------- -// ISP lookup via ipinfo.io (free, no key required for basic fields) -// Results are cached within a run to avoid duplicate lookups for the same IP -// --------------------------------------------------------------------------- - -const ispCache = new Map(); - -async function lookupIsp(ip: string): Promise { - if (ispCache.has(ip)) return ispCache.get(ip)!; - - try { - const token = process.env.IPINFO_TOKEN; - const headers: Record = { Accept: 'application/json' }; - if (token) headers['Authorization'] = `Bearer ${token}`; - - const res = await fetch(`https://ipinfo.io/${ip}/json`, { - headers, - cache: 'no-store', - signal: AbortSignal.timeout(6000), - }); - if (!res.ok) { ispCache.set(ip, null); return null; } - - const data = await res.json(); - // org field format: "AS7922 Comcast Cable Communications, LLC" - const org: string = data.org ?? ''; - const m = org.match(/^(AS\d+)\s+(.+)$/); - - const info: IspInfo = { - isp: m ? m[2] : org, - asn: m ? m[1] : '', - city: data.city ?? '', - region: data.region ?? '', - country: data.country ?? '', - }; - ispCache.set(ip, info); - return info; - } catch { - ispCache.set(ip, null); - return null; - } -} - -// --------------------------------------------------------------------------- -// Zabbix host technical name sanitization -// Zabbix rejects: + ' , . & ( ) and other special chars in the `host` field. -// We sanitize to alphanumeric, spaces, hyphens, underscores only. -// The display `name` field is left as-is (accepts any UTF-8). -// --------------------------------------------------------------------------- - -function sanitizeHostname(name: string): string { - return name - .replace(/[^a-zA-Z0-9 \-_]/g, '') // strip disallowed chars - .replace(/\s+/g, ' ') // collapse multiple spaces - .trim(); -} +// ISP lookup, hostname sanitization, and host param building imported from +// @/lib/services/zabbix-wan-utils // --------------------------------------------------------------------------- // API route @@ -223,7 +167,7 @@ export async function POST(request: NextRequest) { dryRun = false, } = body; - ispCache.clear(); // fresh cache per request + clearIspCache(); // fresh cache per request const encoder = new TextEncoder(); const transform = new TransformStream(); @@ -252,10 +196,7 @@ export async function POST(request: NextRequest) { if (!dryRun) { globalGroupId = await zabbix.ensureHostGroup('Datto RMM Sites'); - for (const name of ['ICMP Ping', 'Template Module ICMP Ping', 'Template Module ICMP Ping by Zabbix agent']) { - const tmpl = await zabbix.findTemplate(name); - if (tmpl) { icmpTemplateId = tmpl.templateid; break; } - } + icmpTemplateId = await discoverIcmpTemplate(zabbix); } // Load site → Autotask mappings (keyed by RMM site UID) @@ -364,75 +305,26 @@ export async function POST(request: NextRequest) { try { const onlineCount = devices.filter((d) => d.online && !d.suspended && !d.deleted).length; - const templates = icmpTemplateId ? [{ templateid: icmpTemplateId }] : undefined; - // Build groups: always global, + per-client, + per-ISP - const groups: Array<{ groupid: string }> = [{ groupid: globalGroupId }]; - - if (mapping) { - const clientGroupId = await zabbix.ensureHostGroup(`Clients/${mapping.companyName}`); - groups.push({ groupid: clientGroupId }); - } - if (ispInfo?.isp) { - const ispGroupId = await zabbix.ensureHostGroup(`ISP/${ispInfo.isp}`); - groups.push({ groupid: ispGroupId }); - } - - // Build macros: Autotask identity + ISP context - const macros: ZabbixHostMacro[] = []; - if (mapping) { - macros.push( - { macro: '{$AUTOTASK_COMPANY_ID}', value: String(mapping.companyId), description: 'Autotask company ID' }, - { macro: '{$AUTOTASK_COMPANY_NAME}', value: mapping.companyName, description: 'Autotask company name' }, - { macro: '{$RMM_SITE_UID}', value: site.uid, description: 'Datto RMM site UID' }, - ); - } - if (ispInfo) { - macros.push( - { macro: '{$ISP_NAME}', value: ispInfo.isp, description: 'ISP / carrier name' }, - { macro: '{$ASN}', value: ispInfo.asn, description: 'Autonomous System Number' }, - { macro: '{$ISP_CITY}', value: ispInfo.city, description: 'City (from IP geolocation)' }, - { macro: '{$ISP_REGION}', value: ispInfo.region, description: 'Region (from IP geolocation)' }, - { macro: '{$ISP_COUNTRY}', value: ispInfo.country, description: 'Country code (from IP geolocation)' }, - ); - } - if (multiWan) { - macros.push({ macro: '{$MULTI_WAN_IPS}', value: allIps.join(', '), description: 'All public IPs seen (multi-WAN site)' }); - } - - // Build tags: for dashboard filtering and problem correlation - const tags: ZabbixHostTag[] = [{ tag: 'source', value: 'datto-rmm' }]; - if (mapping) { - tags.push({ tag: 'client', value: mapping.companyName }); - } - if (ispInfo?.isp) { - tags.push({ tag: 'isp', value: ispInfo.isp }); - } - if (ispInfo?.asn) { - tags.push({ tag: 'asn', value: ispInfo.asn }); - } - if (multiWan) { - tags.push({ tag: 'multi-wan', value: 'true' }); - } - if (singleDeviceFallback) { - tags.push({ tag: 'single-device-fallback', value: 'true' }); - } - - const description = [ - `Datto RMM site – WAN IP from ${onlineCount} online devices`, - ispInfo ? `ISP: ${ispInfo.isp} (${ispInfo.asn}) — ${ispInfo.city}, ${ispInfo.region}, ${ispInfo.country}` : null, - multiWan ? `Multi-WAN detected: ${allIps.join(', ')}` : null, - singleDeviceFallback ? `Note: IP sourced from single device (no multi-device confirmation)` : null, - ].filter(Boolean).join('\n'); - - const { action, hostid } = await zabbix.upsertHost({ - host: sanitizeHostname(site.name), name: site.name, description, - interfaces: [{ type: 1, main: 1, useip: 1, ip: wanIp, dns: '', port: '10050' }], - groups, templates, - macros: macros.length > 0 ? macros : undefined, - tags, + const hostParams = await buildHostParams({ + siteName: site.name, + wanIp, + companyId: mapping?.companyId, + companyName: mapping?.companyName, + rmmSiteUid: site.uid, + ispInfo, + multiWan, + allIps, + singleDeviceFallback, + onlineDeviceCount: onlineCount, + source: 'datto-rmm', + icmpTemplateId, + globalGroupId, + zabbix, }); + const { action, hostid } = await zabbix.upsertHost(hostParams); + if (action === 'created') stats.created++; else stats.updated++; await send({ type: 'site', result: { diff --git a/app/api/zoom/sync/route.ts b/app/api/zoom/sync/route.ts new file mode 100644 index 0000000..25f67ab --- /dev/null +++ b/app/api/zoom/sync/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isZoomConfigured } from '@/lib/services/zoom-factory'; +import { getZoomSyncService } from '@/lib/services/zoom-sync-service'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function POST(_request: NextRequest) { + if (!isZoomConfigured()) { + return NextResponse.json( + { error: 'Zoom credentials not configured' }, + { status: 503 } + ); + } + + const service = getZoomSyncService(); + + if (service.isSyncInProgress()) { + return NextResponse.json({ error: 'Zoom sync already in progress' }, { status: 409 }); + } + + // Fire-and-forget + service.sync().catch(err => { + console.error('[ZOOM-SYNC] Background sync failed:', err); + }); + + return NextResponse.json({ started: true }); +} + +export async function GET(_request: NextRequest) { + const service = getZoomSyncService(); + + let lastSynced: Date | null = null; + try { + const result = await postgresClient.query( + `SELECT MAX(synced_at) as last_synced FROM zoom_users` + ); + lastSynced = result.rows[0]?.last_synced ?? null; + } catch { + // Table may not exist yet + } + + return NextResponse.json({ + isSyncing: service.isSyncInProgress(), + lastSynced, + configured: isZoomConfigured(), + }); +} diff --git a/app/backup-status/page.tsx b/app/backup-status/page.tsx index d9a8709..2cb271b 100644 --- a/app/backup-status/page.tsx +++ b/app/backup-status/page.tsx @@ -8,8 +8,11 @@ import { BackupSummaryCards } from '@/components/backup/backup-summary-cards'; import { CompanyBackupTable, CompanyBackupRow } from '@/components/backup/company-backup-table'; import { ComplianceSummaryCards } from '@/components/backup/compliance-summary-cards'; import { ComplianceDetailTable } from '@/components/backup/compliance-detail-table'; -import { RefreshCw } from 'lucide-react'; +import { ContractCoverageTable } from '@/components/backup/contract-coverage-table'; +import { RefreshCw, CheckCircle2, AlertTriangle, XCircle, Clock } from 'lucide-react'; import { Skeleton } from '@/components/ui/skeleton'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { RpoJobSummary } from '@/lib/services/veeam-rpo-service'; interface BackupStatusData { totalProtectedWorkloads: number; @@ -22,6 +25,18 @@ interface BackupStatusData { lastSyncAt: string | null; } +interface RpoData { + summary: { + total: number; + healthy: number; + breached: number; + withOpenTicket: number; + critical: number; + high: number; + }; + jobs: RpoJobSummary[]; +} + interface ComplianceData { summary: { totalContractedDevices: number; @@ -44,23 +59,33 @@ function timeAgo(dateStr: string | null): string { return `${Math.floor(hours / 24)}d ago`; } +function timeAgoHours(hours: number | null): string { + if (hours === null) return 'Never'; + if (hours < 1) return 'Just now'; + if (hours < 24) return `${Math.round(hours)}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + export default function BackupStatusPage() { const [status, setStatus] = useState(null); const [companies, setCompanies] = useState([]); const [compliance, setCompliance] = useState(null); + const [rpo, setRpo] = useState(null); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); const fetchData = async () => { try { - const [statusRes, companiesRes, complianceRes] = await Promise.all([ + const [statusRes, companiesRes, complianceRes, rpoRes] = await Promise.all([ fetch('/api/veeam/backup-status').then(r => r.json()), fetch('/api/veeam/companies').then(r => r.json()), fetch('/api/veeam/compliance').then(r => r.json()), + fetch('/api/veeam/rpo-check').then(r => r.json()), ]); setStatus(statusRes); setCompanies(Array.isArray(companiesRes) ? companiesRes : []); setCompliance(complianceRes); + setRpo(rpoRes); } catch (error) { console.error('Failed to fetch backup status:', error); } finally { @@ -106,7 +131,7 @@ export default function BackupStatusPage() { if (loading) { return ( -
+
{[...Array(5)].map((_, i) => )}
@@ -117,12 +142,20 @@ export default function BackupStatusPage() { return (
-
+
Backup Overview + + RPO Status + {rpo && rpo.summary.breached > 0 && ( + + {rpo.summary.breached} + + )} + Contract Compliance {compliance && (compliance.summary.contractedNotBackedUp > 0 || compliance.summary.backedUpNotContracted > 0) && ( @@ -165,6 +198,110 @@ export default function BackupStatusPage() { + + {rpo && ( + <> + {/* Summary Cards */} +
+ + + Healthy Jobs + + + +
{rpo.summary.healthy}
+

of {rpo.summary.total} total

+
+
+ + + RPO Breached + + + +
{rpo.summary.breached}
+

{rpo.summary.withOpenTicket} with open ticket

+
+
+ + + Critical + + + +
{rpo.summary.critical}
+

{rpo.summary.high} high priority

+
+
+ + + Compliance Rate + + + +
+ {rpo.summary.total > 0 ? Math.round((rpo.summary.healthy / rpo.summary.total) * 100) : 0}% +
+

jobs within RPO window

+
+
+
+ + {/* Job Table */} +
+ + + + + + + + + + + + + {rpo.jobs.map((job) => ( + + + + + + + + + ))} + {rpo.jobs.length === 0 && ( + + + + )} + +
JobOrganizationLast BackupStatusTicketFailure Reason
{job.job_name}{job.org_name}{timeAgoHours(job.hours_since_backup)} + {job.is_breached ? ( + Breached + ) : ( + Healthy + )} + + {job.open_ticket ? ( + + {job.open_ticket.at_ticket_number} ({job.open_ticket.priority_level}) + + ) : ( + + )} + + {job.failure_category ?? '—'} +
No workstation jobs found
+
+ + )} +
+ {compliance && ( <> @@ -174,7 +311,25 @@ export default function BackupStatusPage() { contractedNotBackedUp={compliance.summary.contractedNotBackedUp} backedUpNotContracted={compliance.summary.backedUpNotContracted} /> - + + + Contract Coverage + + Mismatches + {(compliance.summary.contractedNotBackedUp + compliance.summary.backedUpNotContracted) > 0 && ( + + {compliance.summary.contractedNotBackedUp + compliance.summary.backedUpNotContracted} + + )} + + + + + + + + + )} diff --git a/app/engagement/page.tsx b/app/engagement/page.tsx new file mode 100644 index 0000000..b91469c --- /dev/null +++ b/app/engagement/page.tsx @@ -0,0 +1,1297 @@ +'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]} + )} +
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ )} + +
+ ); + })()} +
+
+
+
+ ); +} diff --git a/app/engagement/profile/page.tsx b/app/engagement/profile/page.tsx new file mode 100644 index 0000000..db65e1d --- /dev/null +++ b/app/engagement/profile/page.tsx @@ -0,0 +1,648 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, + CartesianGrid, + RadarChart, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + Radar, +} from 'recharts'; +import { Users, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface UserOption { + graphUserId: string; + displayName: string; + email: string; + jobTitle: string | null; + department: string | null; +} + +interface DayData { + date: string; + hoursWorked: number; + billableHours: number; +} + +interface MonthData { + month: string; + hoursWorked: number; + billableHours: number; + daysWorked: number; + teamsMessages: number; + teamsPrivateMessages: number; + teamsCalls: number; + meetingsAttended: number; + meetingsOrganized: number; + emailsSent: number; + emailsReceived: number; + totalMeetings: number; + clientMeetings: number; + meetingDurationMinutes: number; + zoomCalls: number; + zoomClientCalls: number; +} + +interface HistoryData { + user: { + id: string; + displayName: string; + email: string; + jobTitle: string | null; + department: string | null; + autotaskResourceId: number | null; + }; + daily: DayData[]; + monthly: MonthData[]; +} + +// Heat level class names — must be full strings for Tailwind to include them +const HEAT_CLASSES = [ + 'bg-muted/50', + 'bg-emerald-100 dark:bg-emerald-950', + 'bg-emerald-300 dark:bg-emerald-800', + 'bg-emerald-500 dark:bg-emerald-600', + 'bg-emerald-700 dark:bg-emerald-400', +]; + +function hoursLevel(h: number): number { + if (h <= 0) return 0; + if (h < 2) return 1; + if (h < 5) return 2; + if (h < 7) return 3; + return 4; +} + +function ActivityHeatmap({ daily }: { daily: DayData[] }) { + const dailyMap: Record = {}; + for (const d of daily) dailyMap[d.date] = d; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Start from 52 weeks ago, padded back to Monday + const startDate = new Date(today); + startDate.setDate(startDate.getDate() - 363); + const dow = (startDate.getDay() + 6) % 7; // Mon=0 … Sun=6 + startDate.setDate(startDate.getDate() - dow); + + const yearAgo = new Date(today); + yearAgo.setFullYear(yearAgo.getFullYear() - 1); + + // Build weeks + const weeks: Array> = []; + const cursor = new Date(startDate); + while (cursor <= today) { + const week: Array<{ date: string; inRange: boolean }> = []; + for (let i = 0; i < 7; i++) { + const key = cursor.toISOString().slice(0, 10); + week.push({ date: key, inRange: cursor >= yearAgo && cursor <= today }); + cursor.setDate(cursor.getDate() + 1); + } + weeks.push(week); + } + + // Month label: track where each month starts + const monthLabels: Array<{ weekIndex: number; label: string }> = []; + let lastMonth = -1; + weeks.forEach((week, wi) => { + const d = new Date(week[0].date + 'T00:00:00'); + const m = d.getMonth(); + if (m !== lastMonth) { + monthLabels.push({ + weekIndex: wi, + label: d.toLocaleDateString('en-US', { month: 'short' }), + }); + lastMonth = m; + } + }); + + const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', 'Sun']; + + return ( +
+
+ {/* Day labels */} +
+ {DAY_LABELS.map((label, i) => ( +
+ {label} +
+ ))} +
+ + {/* Grid */} +
+ {/* Month labels */} +
+ {weeks.map((_, wi) => { + const ml = monthLabels.find(m => m.weekIndex === wi); + return ( +
+ {ml && ( + + {ml.label} + + )} +
+ ); + })} +
+ + {/* Cells */} +
+ {weeks.map((week, wi) => ( +
+ {week.map((cell, di) => { + const data = dailyMap[cell.date]; + const hours = data?.hoursWorked ?? 0; + const level = cell.inRange ? hoursLevel(hours) : 0; + const isFuture = cell.date > today.toISOString().slice(0, 10); + return ( +
+ ); + })} +
+ ))} +
+
+
+ + {/* Legend */} +
+ Less + {HEAT_CLASSES.map((cls, i) => ( +
+ ))} + More +
+
+ ); +} + +function buildRadarData(monthly: MonthData[]) { + const active = monthly.filter(m => m.hoursWorked > 0 || m.teamsMessages > 0 || m.emailsSent > 0); + if (active.length === 0) return []; + + const avg = (fn: (m: MonthData) => number) => + active.reduce((s, m) => s + fn(m), 0) / active.length; + + const avgHours = avg(m => m.hoursWorked); + const avgBillable = avg(m => m.billableHours); + const avgMeetings = avg(m => m.totalMeetings); + const avgComms = avg(m => m.teamsMessages + m.emailsSent); + const avgCalls = avg(m => m.zoomClientCalls + m.teamsCalls); + + return [ + { + subject: 'Utilization', + value: Math.min(100, Math.round((avgHours / 160) * 100)), + fullMark: 100, + }, + { + subject: 'Billable %', + value: avgHours > 0 ? Math.round((avgBillable / avgHours) * 100) : 0, + fullMark: 100, + }, + { + subject: 'Meetings', + value: Math.min(100, Math.round((avgMeetings / 25) * 100)), + fullMark: 100, + }, + { + subject: 'Comms', + value: Math.min(100, Math.round((avgComms / 400) * 100)), + fullMark: 100, + }, + { + subject: 'Client Calls', + value: Math.min(100, Math.round((avgCalls / 15) * 100)), + fullMark: 100, + }, + ]; +} + +function monthLabel(m: string) { + return new Date(m + '-02').toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); +} + +interface BackfillStatus { + running: boolean; + started: string | null; + processed: number; + total: number; + currentUser: string | null; + errors: number; + done: boolean; + log: string[]; +} + +export default function EngagementProfilePage() { + const [users, setUsers] = useState([]); + const [usersLoading, setUsersLoading] = useState(true); + const [selectedUserId, setSelectedUserId] = useState(''); + const [history, setHistory] = useState(null); + const [historyLoading, setHistoryLoading] = useState(false); + const [backfill, setBackfill] = useState(null); + const [backfillStarting, setBackfillStarting] = useState(false); + + useEffect(() => { + (async () => { + try { + const res = await fetch('/api/engagement/users?period=D30&sort=display_name&order=asc'); + const data = await res.json(); + setUsers(data.users ?? []); + } catch {} + setUsersLoading(false); + })(); + }, []); + + const loadHistory = useCallback(async (userId: string) => { + setHistoryLoading(true); + setHistory(null); + try { + const res = await fetch(`/api/engagement/user/${userId}/history`); + const data = await res.json(); + setHistory(data); + } catch {} + setHistoryLoading(false); + }, []); + + const handleSelect = (userId: string) => { + setSelectedUserId(userId); + loadHistory(userId); + }; + + const startBackfill = async () => { + setBackfillStarting(true); + try { + await fetch('/api/engagement/backfill-meetings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ monthsBack: 12 }), + }); + pollBackfill(); + } catch {} + setBackfillStarting(false); + }; + + const pollBackfill = useCallback(async () => { + const res = await fetch('/api/engagement/backfill-meetings').catch(() => null); + if (!res) return; + const data: BackfillStatus = await res.json(); + setBackfill(data); + if (data.running) setTimeout(pollBackfill, 2000); + else if (data.done && selectedUserId) loadHistory(selectedUserId); + }, [selectedUserId, loadHistory]); + + useEffect(() => { + fetch('/api/engagement/backfill-meetings').then(r => r.json()).then((d: BackfillStatus) => { + setBackfill(d); + if (d.running) setTimeout(pollBackfill, 2000); + }).catch(() => {}); + }, [pollBackfill]); + + const monthly = history?.monthly ?? []; + const radarData = buildRadarData(monthly); + + const totalHours = monthly.reduce((s, m) => s + m.hoursWorked, 0); + const totalBillable = monthly.reduce((s, m) => s + m.billableHours, 0); + const billablePct = totalHours > 0 ? Math.round((totalBillable / totalHours) * 100) : 0; + const activeMonths = monthly.filter(m => m.hoursWorked > 0).length; + const peakMonth = monthly.reduce( + (best, m) => (m.hoursWorked > (best?.hoursWorked ?? 0) ? m : best), + null as MonthData | null + ); + + const barData = monthly.map(m => ({ + name: m.month.slice(5), + billable: parseFloat(m.billableHours.toFixed(1)), + nonBillable: parseFloat((m.hoursWorked - m.billableHours).toFixed(1)), + })); + + return ( +
+
+
+

Employee Profile

+

12-month activity overview

+
+ + +
+ + {/* Backfill panel */} + {backfill && (backfill.running || backfill.done) ? ( + 0 ? 'border-yellow-400' : 'border-green-400')}> + +
+
+ {backfill.running && } + {backfill.running + ? `Backfilling meetings… ${backfill.processed}/${backfill.total} users` + : `Backfill complete — ${backfill.processed} users, ${backfill.errors} errors`} +
+ {backfill.running && backfill.currentUser && ( + {backfill.currentUser} + )} +
+ {backfill.running && backfill.total > 0 && ( +
+
+
+ )} + {backfill.log.length > 0 && ( +
+                {backfill.log.slice(-20).join('\n')}
+              
+ )} + + + ) : ( +
+ +
+ )} + + {!selectedUserId && ( + + + +

Select an employee

+

+ Choose an employee above to see their 12-month activity profile +

+
+
+ )} + + {selectedUserId && historyLoading && ( +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+ )} + + {selectedUserId && !historyLoading && history && ( + <> + {/* User header */} +
+

{history.user.displayName}

+
+ {history.user.jobTitle && {history.user.jobTitle}} + {history.user.jobTitle && history.user.department && ·} + {history.user.department && {history.user.department}} + {(history.user.jobTitle || history.user.department) && ·} + {history.user.email} +
+
+ + {/* Year stats */} +
+ + +

Total Hours

+

{totalHours.toFixed(0)}

+

over 12 months

+
+
+ + +

Billable Rate

+

{billablePct}%

+

{totalBillable.toFixed(0)}h billable

+
+
+ + +

Avg hrs / month

+

+ {activeMonths > 0 ? (totalHours / activeMonths).toFixed(0) : '—'} +

+

{activeMonths} active months

+
+
+ + +

Peak Month

+

+ {peakMonth ? peakMonth.hoursWorked.toFixed(0) + 'h' : '—'} +

+

+ {peakMonth ? monthLabel(peakMonth.month) : ''} +

+
+
+
+ + {/* Activity heatmap */} + + + Activity Calendar +

Daily hours worked — last 12 months

+
+ + {history.daily.length > 0 ? ( + + ) : ( +

+ No time entry data available +

+ )} +
+
+ + {/* Monthly bar chart + radar */} +
+ + + Monthly Hours +

Billable vs non-billable

+
+ + + + + + + [ + `${value}h`, + name === 'billable' ? 'Billable' : 'Non-billable', + ]} + contentStyle={{ fontSize: 12 }} + /> + (v === 'billable' ? 'Billable' : 'Non-billable')} + wrapperStyle={{ fontSize: 11 }} + /> + + + + + +
+ + + + Activity Signature +

12-month average profile

+
+ + {radarData.length > 0 ? ( + + + + + + + ) : ( +

No data

+ )} +
+
+
+ + {/* Monthly breakdown table */} + + + Monthly Breakdown + + +
+ + + + + + + + + + + + + + + + {[...monthly].reverse().map(m => { + const pct = m.hoursWorked > 0 ? Math.round((m.billableHours / m.hoursWorked) * 100) : 0; + const isEmpty = + m.hoursWorked === 0 && m.teamsMessages === 0 && m.emailsSent === 0; + const totalCalls = m.zoomClientCalls + m.teamsCalls; + return ( + + + + + + + + + + + + ); + })} + +
MonthHoursBillableBill %DaysMeetingsMessagesEmailsCalls
{monthLabel(m.month)} + {m.hoursWorked > 0 ? m.hoursWorked.toFixed(1) : '—'} + + {m.billableHours > 0 ? m.billableHours.toFixed(1) : '—'} + + {m.hoursWorked > 0 ? `${pct}%` : '—'} + + {m.daysWorked > 0 ? m.daysWorked : '—'} + + {m.totalMeetings > 0 ? m.totalMeetings : '—'} + + {m.teamsMessages > 0 ? m.teamsMessages : '—'} + + {m.emailsSent > 0 ? m.emailsSent : '—'} + + {totalCalls > 0 ? totalCalls : '—'} +
+
+
+
+ + )} +
+ ); +} diff --git a/app/globals.css b/app/globals.css index c5aacad..ebdc6f5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -51,18 +51,18 @@ --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.488 0.243 264.376); /* Blue */ + --primary: oklch(0.55 0.16 220); /* Logo blue */ --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.696 0.17 162.48); /* Teal */ + --accent: oklch(0.55 0.16 220); /* Logo blue */ --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); - --ring: oklch(0.488 0.243 264.376); + --ring: oklch(0.55 0.16 220); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); @@ -70,12 +70,12 @@ --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary: oklch(0.55 0.16 220); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --sidebar-ring: oklch(0.55 0.16 220); } .dark { @@ -85,31 +85,31 @@ --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.65 0.22 264.376); /* Bright Blue for dark mode */ + --primary: oklch(0.62 0.17 220); /* Logo blue - bright for dark mode */ --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.75 0.15 162.48); /* Bright Teal for dark mode */ - --accent-foreground: oklch(0.145 0 0); + --accent: oklch(0.62 0.17 220); /* Logo blue */ + --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); - --ring: oklch(0.65 0.22 264.376); + --ring: oklch(0.62 0.17 220); --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); + --chart-2: oklch(0.62 0.17 220); /* Logo blue */ --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.65 0.22 264.376); + --sidebar-primary: oklch(0.62 0.17 220); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.65 0.22 264.376); + --sidebar-ring: oklch(0.62 0.17 220); } @layer base { diff --git a/components/backup/compliance-detail-table.tsx b/components/backup/compliance-detail-table.tsx index a9fe290..44cd745 100644 --- a/components/backup/compliance-detail-table.tsx +++ b/components/backup/compliance-detail-table.tsx @@ -1,9 +1,15 @@ 'use client'; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Table, TableBody, @@ -12,7 +18,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import { Search } from 'lucide-react'; +import { Search, CheckCircle2, XCircle, Loader2, ExternalLink, Package } from 'lucide-react'; interface ComplianceMismatch { id: number; @@ -25,15 +31,224 @@ interface ComplianceMismatch { device_name: string; contract_name: string | null; veeam_workload_name: string | null; + billing_covered: boolean | null; + billing_contract_name: string | null; + billing_contracted_qty: number | null; + billing_contract_id: number | null; + coverage_source: string | null; +} + +interface ContractService { + id: number; + service_id: number | null; + display_name: string; + description: string | null; + unit_price: number | null; + unit_cost: number | null; + quantity: number | null; + adjusted_price: number | null; + period_label: string | null; + start_date: string | null; + end_date: string | null; +} + +interface ContractDetail { + id: number; + contract_name: string; + company_name: string; + status: number; + contract_type: number | null; + start_date: string | null; + end_date: string | null; + description: string | null; } interface ComplianceDetailTableProps { mismatches: ComplianceMismatch[]; } +const BACKUP_SERVICE_PATTERNS = [ + /workstation.*backup/i, + /w\/ backup/i, + /windows server/i, + /server virtual/i, + /server phys/i, + /esxi host/i, + /wulf 365 it complete (endpoint|server)/i, + /wulf it complete \((server|endpoint)\)/i, +]; + +function isBackupService(name: string): boolean { + return BACKUP_SERVICE_PATTERNS.some((re) => re.test(name)); +} + +function ContractCoverageModal({ + contractId, + companyName, + open, + onClose, +}: { + contractId: number | null; + companyName: string | null; + open: boolean; + onClose: () => void; +}) { + const [data, setData] = useState<{ contract: ContractDetail; services: ContractService[] } | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loadedId, setLoadedId] = useState(null); + + const load = useCallback(async (id: number) => { + if (loadedId === id) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/data/contracts/${id}/services`); + if (!res.ok) throw new Error('Failed to load contract details'); + const json = await res.json(); + setData(json); + setLoadedId(id); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, [loadedId]); + + if (open && contractId && loadedId !== contractId && !loading) { + load(contractId); + } + + const contract = data?.contract; + const services = data?.services ?? []; + const backupServices = services.filter((s) => isBackupService(s.display_name)); + const otherServices = services.filter((s) => !isBackupService(s.display_name)); + + return ( + !v && onClose()}> + + + + + Contract Coverage + {contract && ( + — {contract.company_name} + )} + + + + {loading && ( +
+ +
+ )} + + {error && ( +

{error}

+ )} + + {!loading && !error && contract && ( +
+ {/* Contract header */} +
+
+
+

{contract.contract_name}

+

{contract.company_name}

+
+ + View in Autotask + +
+
+ {contract.start_date && ( + Start: {new Date(contract.start_date).toLocaleDateString()} + )} + {contract.end_date && ( + End: {new Date(contract.end_date).toLocaleDateString()} + )} + + + {contract.status === 1 ? 'Active' : 'Inactive'} + +
+
+ + {/* Backup-relevant services */} + {backupServices.length > 0 && ( +
+

Backup-Covered Services

+ +
+ )} + + {/* Other services */} + {otherServices.length > 0 && ( +
+

All Services ({services.length})

+ +
+ )} + + {services.length === 0 && ( +

No service lines found for this contract.

+ )} +
+ )} +
+
+ ); +} + +function ServiceTable({ services, highlight }: { services: ContractService[]; highlight?: boolean }) { + return ( +
+ + + + Service + Unit Price + Unit Cost + + + + {services.map((s) => ( + + + {highlight && } + {s.display_name} + + + {s.unit_price != null ? `$${Number(s.unit_price).toFixed(2)}` : '—'} + + + {s.unit_cost != null ? `$${Number(s.unit_cost).toFixed(2)}` : '—'} + + + ))} + +
+
+ ); +} + export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps) { const [search, setSearch] = useState(''); const [typeFilter, setTypeFilter] = useState('all'); + const [modalContractId, setModalContractId] = useState(null); + const [modalCompanyName, setModalCompanyName] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + const openModal = (contractId: number, companyName: string | null) => { + setModalContractId(contractId); + setModalCompanyName(companyName); + setModalOpen(true); + }; const filtered = mismatches.filter((m) => { const matchesSearch = @@ -82,7 +297,7 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps Device Issue Backup UDF - Contract + Contract Coverage Veeam Workload @@ -114,7 +329,34 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps {m.backup_type_udf || '-'} - {m.contract_name || '-'} + + {m.billing_covered && m.billing_contract_id ? ( + + ) : m.billing_covered ? ( +
+ + + {m.billing_contract_name || 'Active'} + +
+ ) : ( +
+ + No backup contract +
+ )} +
{m.veeam_workload_name || '-'} )) @@ -122,6 +364,13 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps
+ + setModalOpen(false)} + />
); } diff --git a/components/backup/contract-coverage-table.tsx b/components/backup/contract-coverage-table.tsx new file mode 100644 index 0000000..81db6a3 --- /dev/null +++ b/components/backup/contract-coverage-table.tsx @@ -0,0 +1,362 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Search, + ChevronRight, + ChevronDown, + Server, + Monitor, + Mail, + Package, + Loader2, +} from 'lucide-react'; + +interface ServiceLine { + cs_id: number; + contract_id: number; + contract_name: string; + line_name: string; + unit_price: number | null; + unit_cost: number | null; + category: 'server' | 'workstation' | 'm365' | 'other'; +} + +interface CoverageRow { + company_id: number; + company_name: string; + contracted: { servers: number; workstations: number; m365: number; other: number }; + deployed: { servers: number; workstations: number; other: number }; + lines: ServiceLine[]; +} + +const CATEGORY_COLORS: Record = { + server: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', + workstation: 'bg-purple-500/10 text-purple-600 dark:text-purple-400', + m365: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', + other: 'bg-muted text-muted-foreground', +}; + +const CATEGORY_LABELS: Record = { + server: 'Server', + workstation: 'Workstation', + m365: 'M365', + other: 'Other', +}; + +function DeltaBadge({ contracted, deployed }: { contracted: number; deployed: number }) { + const delta = deployed - contracted; + if (contracted === 0 && deployed === 0) return ; + if (delta === 0) return ; + if (delta > 0) + return ( + + +{delta} + + ); + return ( + + {delta} + + ); +} + +function CountCell({ + contracted, + deployed, +}: { + contracted: number; + deployed: number; +}) { + const delta = deployed - contracted; + const hasData = contracted > 0 || deployed > 0; + if (!hasData) return ; + + const color = + delta === 0 + ? 'text-green-600 dark:text-green-400' + : delta > 0 + ? 'text-amber-600 dark:text-amber-400' + : 'text-red-600 dark:text-red-400'; + + return ( + + {deployed}/{contracted} + + ); +} + +function ClientRow({ row }: { row: CoverageRow }) { + const [expanded, setExpanded] = useState(false); + + const hasAnyData = + row.contracted.servers + row.contracted.workstations + row.contracted.m365 + + row.deployed.servers + row.deployed.workstations > 0; + + // Group service lines by contract + const byContract = useMemo(() => { + const map = new Map(); + for (const l of row.lines) { + if (!map.has(l.contract_id)) { + map.set(l.contract_id, { contract_name: l.contract_name, lines: [] }); + } + map.get(l.contract_id)!.lines.push(l); + } + return [...map.values()]; + }, [row.lines]); + + return ( + <> + setExpanded((v) => !v)} + > + {/* Expand toggle + Client */} + +
+ + {expanded ? ( + + ) : ( + + )} + + {row.company_name} +
+
+ + {/* Servers deployed/contracted */} + + + + + {/* Workstations deployed/contracted */} + + + + + {/* M365 contracted (no Veeam deployed count for M365) */} + + {row.contracted.m365 > 0 ? ( + + {row.contracted.m365} + + ) : ( + + )} + + + {/* Total service lines */} + + {row.lines.length} + +
+ + {/* Expanded detail rows */} + {expanded && ( + + +
+ {byContract.length === 0 ? ( +

No contract service lines found.

+ ) : ( + byContract.map((contract) => { + const backupLines = contract.lines.filter( + (l) => l.category === 'server' || l.category === 'workstation' + ); + if (backupLines.length === 0) return null; + return ( +
+

+ {contract.contract_name} +

+
+ + + + + + + + + {backupLines.map((line) => ( + + + + + ))} + +
ServiceCategory
+ {line.category === 'server' ? ( + + ) : ( + + )} + {line.line_name} + + + {CATEGORY_LABELS[line.category]} + +
+
+
+ ); + }) + )} +
+
+
+ )} + + ); +} + +export function ContractCoverageTable() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState<'all' | 'gap' | 'over' | 'matched'>('all'); + + useEffect(() => { + fetch('/api/veeam/contract-coverage') + .then((r) => r.json()) + .then((d) => setRows(d.rows ?? [])) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + const filtered = useMemo(() => { + return rows.filter((r) => { + const matchesSearch = r.company_name.toLowerCase().includes(search.toLowerCase()); + if (!matchesSearch) return false; + if (filter === 'all') return true; + + const serverDelta = r.deployed.servers - r.contracted.servers; + const wsDelta = r.deployed.workstations - r.contracted.workstations; + + if (filter === 'gap') return serverDelta < 0 || wsDelta < 0; + if (filter === 'over') return serverDelta > 0 || wsDelta > 0; + if (filter === 'matched') + return ( + r.contracted.servers > 0 || r.contracted.workstations > 0 + ? serverDelta === 0 && wsDelta === 0 + : false + ); + return true; + }); + }, [rows, search, filter]); + + const FILTERS: { key: typeof filter; label: string }[] = [ + { key: 'all', label: 'All' }, + { key: 'gap', label: 'Under-deployed' }, + { key: 'over', label: 'Over-deployed' }, + { key: 'matched', label: 'Matched' }, + ]; + + return ( +
+ {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + className="pl-9 h-8 text-sm" + /> +
+
+ {FILTERS.map((f) => ( + + ))} +
+ {filtered.length} clients +
+ + {/* Legend */} +
+ Counts: deployed / contracted + + Matched + + + Over-deployed + + + Gap + +
+ + {/* Table */} +
+ {loading ? ( +
+ +
+ ) : ( + + + + Client + +
+ Servers +
+
+ +
+ Workstations +
+
+ +
+ M365 +
+
+ +
+ Lines +
+
+
+
+ + {filtered.length === 0 ? ( + + + No clients match the current filter + + + ) : ( + filtered.map((row) => ) + )} + +
+ )} +
+
+ ); +} diff --git a/components/navigation/app-navigation.tsx b/components/navigation/app-navigation.tsx index 6350691..e59a503 100644 --- a/components/navigation/app-navigation.tsx +++ b/components/navigation/app-navigation.tsx @@ -21,6 +21,9 @@ import { Zap, Radio, Shield, + Users, + TrendingUp, + Sun, } from 'lucide-react'; import { NavigationMenu, @@ -61,6 +64,24 @@ const navigationItems: NavItem[] = [ icon: HardDrive, description: 'Veeam backup health and compliance' }, + { + title: 'Engagement', + icon: Users, + children: [ + { + title: 'Overview', + href: '/engagement', + icon: Users, + description: 'Staff activity and engagement metrics', + }, + { + title: 'Employee Profile', + href: '/engagement/profile', + icon: TrendingUp, + description: '12-month activity calendar and performance profile', + }, + ], + }, { title: 'Admin', icon: Activity, @@ -119,6 +140,12 @@ const navigationItems: NavItem[] = [ icon: Zap, description: 'Automated webhook processing workflows' }, + { + title: 'Morning NOC Summary', + href: '/admin/morning-summary', + icon: Sun, + description: 'Daily Zabbix overnight summary posted to Teams channels via webhook' + }, { title: 'Notification Channels', href: '/admin/workflow/channels', @@ -157,7 +184,7 @@ export function AppNavigation() { return (
-
+
{/* Logo and App Name */} isActive(child.href)) && "bg-accent" + item.children.some(child => isActive(child.href)) && "bg-primary text-primary-foreground" )}> {item.icon && } {item.title} @@ -193,8 +220,8 @@ export function AppNavigation() {
@@ -218,7 +245,7 @@ export function AppNavigation() { {item.icon && } {item.title} @@ -255,7 +282,7 @@ interface PageHeaderProps { export function PageHeader({ title, description, breadcrumbs, actions }: PageHeaderProps) { return (
-
+
{/* Breadcrumbs */} {breadcrumbs && breadcrumbs.length > 0 && (