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
This commit is contained in:
lorentz 2026-03-11 09:34:51 -04:00
parent 19605f82aa
commit c518eefdb2
61 changed files with 11236 additions and 237 deletions

View file

@ -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<string, { success: boolean; httpStatus?: number; error?: string }>;
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 <span className="text-xs text-muted-foreground">Never sent</span>;
if (status === 'success') return (
<span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3 w-3" /> Success
</span>
);
return (
<span className="flex items-center gap-1 text-xs text-red-500">
<XCircle className="h-3 w-3" /> Failed
</span>
);
}
export default function MorningSummaryPage() {
const [webhooks, setWebhooks] = useState<WebhookConfig[]>([]);
const [config, setConfig] = useState<SummaryConfig | null>(null);
const [history, setHistory] = useState<SummaryRow[]>([]);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [testingId, setTestingId] = useState<number | null>(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 (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-6 space-y-8">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2 ${toast.ok ? 'bg-green-500/10 border border-green-500/30 text-green-400' : 'bg-red-500/10 border border-red-500/30 text-red-400'}`}>
{toast.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
{toast.msg}
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"> Morning NOC Summary</h1>
<p className="text-sm text-muted-foreground mt-1">Scheduled 6:30 AM MonFri · Posts to Teams channels via webhook</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="h-4 w-4 mr-1" /> Refresh</Button>
<Button size="sm" onClick={handleSendAll} disabled={sending}>
{sending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Send className="h-4 w-4 mr-1" />}
Send Now
</Button>
</div>
</div>
{/* Last Run Stats */}
{latestSummary && (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Last Run</h2>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" /> {fmtDate(latestSummary.generated_at)}
{latestSummary.is_weekend_window && <span className="ml-2 px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded text-xs">Weekend</span>}
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className={`text-2xl font-bold ${latestSummary.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}`}>{latestSummary.open_count}</div>
<div className="text-xs text-muted-foreground">Open</div>
</div>
<div className="text-center">
<div className={`text-2xl font-bold ${latestSummary.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}`}>{latestSummary.resolved_count}</div>
<div className="text-xs text-muted-foreground">Resolved</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{latestSummary.mttr_minutes != null ? `${latestSummary.mttr_minutes}m` : '—'}</div>
<div className="text-xs text-muted-foreground">Avg MTTR</div>
</div>
</div>
{/* Delivery results */}
{Object.keys(latestSummary.delivery_status).length > 0 && (
<div className="pt-2 border-t space-y-1">
<p className="text-xs text-muted-foreground font-medium">Delivery</p>
{Object.entries(latestSummary.delivery_status).map(([wid, r]) => {
const webhook = webhooks.find(w => w.id === parseInt(wid));
return (
<div key={wid} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{webhook?.label ?? `Webhook #${wid}`}</span>
{r.success
? <span className="text-green-500 flex items-center gap-1"><CheckCircle2 className="h-3 w-3" /> Delivered</span>
: <span className="text-red-500 flex items-center gap-1"><XCircle className="h-3 w-3" /> {r.error ?? `HTTP ${r.httpStatus}`}</span>
}
</div>
);
})}
</div>
)}
{/* Card preview toggle */}
{latestSummary.card_payload && (
<div className="pt-2 border-t">
<button
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setCardExpanded(v => !v)}
>
{cardExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{cardExpanded ? 'Hide' : 'Show'} card payload
</button>
{cardExpanded && (
<pre className="mt-2 text-xs bg-muted/30 rounded p-3 overflow-auto max-h-64 text-muted-foreground">
{JSON.stringify(latestSummary.card_payload, null, 2)}
</pre>
)}
</div>
)}
</div>
)}
{/* Webhooks */}
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Webhooks</h2>
<Button variant="outline" size="sm" onClick={() => setShowAddWebhook(v => !v)}>
<Plus className="h-3 w-3 mr-1" /> Add
</Button>
</div>
{showAddWebhook && (
<div className="rounded-md border border-dashed p-3 space-y-2 bg-muted/10">
<input
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="Label (e.g. Technical / On-Call)"
value={newLabel}
onChange={e => setNewLabel(e.target.value)}
/>
<input
className="w-full text-sm bg-background border rounded px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring font-mono"
placeholder="Webhook URL"
value={newUrl}
onChange={e => setNewUrl(e.target.value)}
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleAddWebhook} disabled={addingWebhook || !newLabel || !newUrl}>
{addingWebhook ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Add Webhook'}
</Button>
<Button size="sm" variant="ghost" onClick={() => { setShowAddWebhook(false); setNewLabel(''); setNewUrl(''); }}>Cancel</Button>
</div>
</div>
)}
{webhooks.length === 0 && !showAddWebhook && (
<p className="text-sm text-muted-foreground text-center py-4">No webhooks configured. Add one above.</p>
)}
<div className="space-y-2">
{webhooks.map(webhook => (
<div key={webhook.id} className={`flex items-center justify-between rounded-md border px-3 py-2.5 ${webhook.enabled ? 'bg-background' : 'bg-muted/20 opacity-60'}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{webhook.label}</span>
{webhook.enabled
? <span className="text-xs px-1.5 py-0.5 bg-green-500/10 text-green-400 rounded">Enabled</span>
: <span className="text-xs px-1.5 py-0.5 bg-muted/40 text-muted-foreground rounded">Disabled</span>
}
</div>
<div className="flex items-center gap-3 mt-0.5">
<StatusBadge status={webhook.last_status} />
{webhook.last_delivered_at && (
<span className="text-xs text-muted-foreground">{fmtDate(webhook.last_delivered_at)}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
size="sm" variant="ghost"
className="h-7 px-2 text-xs"
disabled={testingId === webhook.id}
onClick={() => handleTest(webhook.id, webhook.label)}
>
{testingId === webhook.id ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Test'}
</Button>
<button
className="p-1.5 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={() => handleToggleWebhook(webhook)}
title={webhook.enabled ? 'Disable' : 'Enable'}
>
{webhook.enabled
? <ToggleRight className="h-4 w-4 text-green-500" />
: <ToggleLeft className="h-4 w-4" />
}
</button>
<button
className="p-1.5 rounded hover:bg-red-500/10 transition-colors text-muted-foreground hover:text-red-500"
onClick={() => handleDeleteWebhook(webhook.id)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
{/* Schedule Config */}
{config && (
<div className="rounded-lg border bg-card p-4 space-y-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Schedule Settings</h2>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Weekend Suppression</p>
<p className="text-xs text-muted-foreground">Skip Saturday & Sunday (cron already limits to MonFri)</p>
</div>
<button onClick={() => handleConfigToggle('weekend_suppression')} className="text-muted-foreground hover:text-foreground transition-colors">
{config.weekend_suppression
? <ToggleRight className="h-6 w-6 text-green-500" />
: <ToggleLeft className="h-6 w-6" />
}
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Monday Extended Window</p>
<p className="text-xs text-muted-foreground">On Mondays, extend window to cover the full weekend (Fri 6 PM Mon 6:30 AM)</p>
</div>
<button onClick={() => handleConfigToggle('monday_extended_window')} className="text-muted-foreground hover:text-foreground transition-colors">
{config.monday_extended_window
? <ToggleRight className="h-6 w-6 text-green-500" />
: <ToggleLeft className="h-6 w-6" />
}
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Outages Only</p>
<p className="text-xs text-muted-foreground">Only show "Unavailable" problems filter out slow response and other non-outage alerts</p>
</div>
<button onClick={() => handleConfigToggle('outages_only')} className="text-muted-foreground hover:text-foreground transition-colors">
{config.outages_only
? <ToggleRight className="h-6 w-6 text-green-500" />
: <ToggleLeft className="h-6 w-6" />
}
</button>
</div>
</div>
</div>
)}
{/* Run History */}
{history.length > 0 && (
<div className="rounded-lg border bg-card p-4 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Recent Runs</h2>
<div className="space-y-1">
{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 (
<div key={row.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
<div className="flex items-center gap-3">
{allOk && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
{anyFail && <AlertTriangle className="h-3.5 w-3.5 text-yellow-500 shrink-0" />}
{statusEntries.length === 0 && <Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<span className="text-muted-foreground">{fmtDate(row.generated_at)}</span>
{row.is_weekend_window && <span className="text-xs px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded">Weekend</span>}
</div>
<div className="flex items-center gap-4">
<span className={row.open_count > 0 ? 'text-red-400' : 'text-muted-foreground'}>
{row.open_count} open
</span>
<span className={row.resolved_count > 0 ? 'text-green-400' : 'text-muted-foreground'}>
{row.resolved_count} resolved
</span>
{row.mttr_minutes != null && (
<span className="text-muted-foreground">{row.mttr_minutes}m MTTR</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View file

@ -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<AbortController | null>(null);
const tableBottomRef = useRef<HTMLDivElement>(null);
// Manual host creation state
const [manualOpen, setManualOpen] = useState(false);
const [manualIp, setManualIp] = useState('');
const [manualSiteName, setManualSiteName] = useState('');
const [manualCompanyId, setManualCompanyId] = useState<string>('');
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 (
<div className="container mx-auto py-8 max-w-6xl space-y-6">
{/* Header */}
@ -410,6 +476,142 @@ export default function ZabbixWanPage() {
</CardContent>
</Card>
{/* Manual Host Creation */}
<Card>
<CardHeader
className="pb-4 cursor-pointer select-none"
onClick={() => setManualOpen((v) => !v)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{manualOpen ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
<Network className="w-4 h-4" />
<CardTitle className="text-base">Manual Host</CardTitle>
</div>
<CardDescription className="mt-0">Create a Zabbix host from an IP address for testing or clients not in RMM</CardDescription>
</div>
</CardHeader>
{manualOpen && (
<CardContent className="space-y-5 pt-0">
<div className="grid grid-cols-2 gap-6">
{/* IP Address */}
<div className="space-y-2">
<Label htmlFor="manual-ip" className="text-sm font-medium">IP Address</Label>
<Input
id="manual-ip"
placeholder="203.0.113.42"
value={manualIp}
onChange={(e) => setManualIp(e.target.value)}
className={`w-56 font-mono ${manualIp && !ipv4Valid ? 'border-destructive' : ''}`}
/>
{manualIp && !ipv4Valid && (
<p className="text-xs text-destructive">Enter a valid IPv4 address</p>
)}
</div>
{/* Site Name */}
<div className="space-y-2">
<Label htmlFor="manual-site" className="text-sm font-medium">Site Name</Label>
<Input
id="manual-site"
placeholder="Acme Corp - Main Office"
value={manualSiteName}
onChange={(e) => setManualSiteName(e.target.value)}
className="w-80"
/>
<p className="text-xs text-muted-foreground">Becomes the Zabbix host display name</p>
</div>
</div>
{/* Client selector */}
<div className="space-y-2">
<Label className="text-sm font-medium flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5" /> Client (optional)
</Label>
<Select value={manualCompanyId} onValueChange={setManualCompanyId} disabled={loadingMappings}>
<SelectTrigger className="w-80">
<SelectValue placeholder={loadingMappings ? 'Loading…' : 'No client (unlinked)'} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No client (unlinked)</SelectItem>
{companies.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Links the host to an Autotask client with macros, tags, and a client host group
</p>
</div>
{/* Dry-run + actions */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex items-center gap-3">
<Switch
id="manual-dry-run"
checked={manualDryRun}
onCheckedChange={setManualDryRun}
/>
<div>
<Label htmlFor="manual-dry-run" className="text-sm font-medium cursor-pointer">
Dry run
</Label>
<p className="text-xs text-muted-foreground">
Preview only no writes to Zabbix
</p>
</div>
</div>
<Button
onClick={handleManualCreate}
disabled={!canCreateManual}
className="gap-2"
>
{manualRunning ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Creating</>
) : (
<><Plus className="w-4 h-4" /> {manualDryRun ? 'Preview' : 'Create Host'}</>
)}
</Button>
</div>
{/* Manual result */}
{manualResult && (
<div className={`rounded-md border p-4 space-y-2 ${
manualResult.action === 'error'
? 'border-destructive/30 bg-destructive/5'
: manualResult.action === 'created'
? 'border-green-500/30 bg-green-500/5'
: manualResult.action === 'updated'
? 'border-blue-500/30 bg-blue-500/5'
: 'border-border bg-muted/20'
}`}>
<div className="flex items-center gap-3">
<ActionBadge action={manualResult.action} />
<span className="font-medium text-sm">{manualResult.siteName}</span>
<span className="font-mono text-sm text-muted-foreground">{manualResult.ip}</span>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
{manualResult.companyName && <span>Client: <strong>{manualResult.companyName}</strong></span>}
{manualResult.isp && <span>ISP: {manualResult.isp}</span>}
{manualResult.asn && <span>{manualResult.asn}</span>}
{manualResult.hostId && <span>Zabbix ID: <strong className="font-mono">{manualResult.hostId}</strong></span>}
{manualResult.dryRun && <span className="italic">Dry run no changes written</span>}
</div>
{manualResult.error && (
<p className="text-xs text-destructive">{manualResult.error}</p>
)}
</div>
)}
</CardContent>
)}
</Card>
{/* Host Manager */}
<HostManager companies={companies} />
{/* Results */}
{(results.length > 0 || running || fatalError) && (
<Card>

View file

@ -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<number, string> = {
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 });
}
}

View file

@ -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<string, { contactId: number; companyId: number | null }>();
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;
}
}

View file

@ -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<string, string> = {
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 });
}
}

View file

@ -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() });
}

View file

@ -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<string, MonthData>();
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 });
}
}

View file

@ -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 });
}
}

View file

@ -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<string, string> = {
D7: '7 days',
D30: '30 days',
D90: '90 days',
};
const interval = intervalMap[period] || '30 days';
const allowedSorts: Record<string, string> = {
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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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
`);

View file

@ -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<number, typeof linesResult.rows> = {};
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: [] });
}
}

View file

@ -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 }
);
}
}

View file

@ -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']<Array<{ interfaces: Array<{ interfaceid: string; main: number }> }>>('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']<Array<{ interfaces: Array<{ interfaceid: string; main: number }> }>>('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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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<string, IspInfo | null>();
async function lookupIsp(ip: string): Promise<IspInfo | null> {
if (ispCache.has(ip)) return ispCache.get(ip)!;
try {
const token = process.env.IPINFO_TOKEN;
const headers: Record<string, string> = { 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<Uint8Array, Uint8Array>();
@ -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: {

View file

@ -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(),
});
}

View file

@ -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<BackupStatusData | null>(null);
const [companies, setCompanies] = useState<CompanyBackupRow[]>([]);
const [compliance, setCompliance] = useState<ComplianceData | null>(null);
const [rpo, setRpo] = useState<RpoData | null>(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 (
<div className="container px-6 py-6 space-y-6">
<div className="container mx-auto px-6 py-6 space-y-6">
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
@ -117,12 +142,20 @@ export default function BackupStatusPage() {
return (
<div>
<div className="container px-6 py-6 space-y-6">
<div className="container mx-auto px-6 py-6 space-y-6">
<Tabs defaultValue="overview" className="space-y-6">
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="overview">Backup Overview</TabsTrigger>
<TabsTrigger value="rpo">
RPO Status
{rpo && rpo.summary.breached > 0 && (
<Badge variant="destructive" className="ml-2 h-5 px-1.5 text-xs">
{rpo.summary.breached}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="compliance">
Contract Compliance
{compliance && (compliance.summary.contractedNotBackedUp > 0 || compliance.summary.backedUpNotContracted > 0) && (
@ -165,6 +198,110 @@ export default function BackupStatusPage() {
<CompanyBackupTable companies={companies} />
</TabsContent>
<TabsContent value="rpo" className="space-y-6">
{rpo && (
<>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Healthy Jobs</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{rpo.summary.healthy}</div>
<p className="text-xs text-muted-foreground">of {rpo.summary.total} total</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">RPO Breached</CardTitle>
<XCircle className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">{rpo.summary.breached}</div>
<p className="text-xs text-muted-foreground">{rpo.summary.withOpenTicket} with open ticket</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Critical</CardTitle>
<AlertTriangle className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">{rpo.summary.critical}</div>
<p className="text-xs text-muted-foreground">{rpo.summary.high} high priority</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Compliance Rate</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{rpo.summary.total > 0 ? Math.round((rpo.summary.healthy / rpo.summary.total) * 100) : 0}%
</div>
<p className="text-xs text-muted-foreground">jobs within RPO window</p>
</CardContent>
</Card>
</div>
{/* Job Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Job</th>
<th className="px-4 py-3 text-left font-medium">Organization</th>
<th className="px-4 py-3 text-left font-medium">Last Backup</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-left font-medium">Ticket</th>
<th className="px-4 py-3 text-left font-medium">Failure Reason</th>
</tr>
</thead>
<tbody>
{rpo.jobs.map((job) => (
<tr key={job.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{job.job_name}</td>
<td className="px-4 py-3 text-muted-foreground">{job.org_name}</td>
<td className="px-4 py-3 text-muted-foreground">{timeAgoHours(job.hours_since_backup)}</td>
<td className="px-4 py-3">
{job.is_breached ? (
<Badge variant="destructive">Breached</Badge>
) : (
<Badge variant="outline" className="text-green-600 border-green-600">Healthy</Badge>
)}
</td>
<td className="px-4 py-3">
{job.open_ticket ? (
<span className={`text-xs font-mono ${
job.open_ticket.priority_level === 'critical' ? 'text-destructive' :
job.open_ticket.priority_level === 'high' ? 'text-orange-500' : 'text-muted-foreground'
}`}>
{job.open_ticket.at_ticket_number} ({job.open_ticket.priority_level})
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-xs truncate">
{job.failure_category ?? '—'}
</td>
</tr>
))}
{rpo.jobs.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">No workstation jobs found</td>
</tr>
)}
</tbody>
</table>
</div>
</>
)}
</TabsContent>
<TabsContent value="compliance" className="space-y-6">
{compliance && (
<>
@ -174,7 +311,25 @@ export default function BackupStatusPage() {
contractedNotBackedUp={compliance.summary.contractedNotBackedUp}
backedUpNotContracted={compliance.summary.backedUpNotContracted}
/>
<ComplianceDetailTable mismatches={compliance.mismatches} />
<Tabs defaultValue="coverage" className="space-y-4">
<TabsList className="h-8">
<TabsTrigger value="coverage" className="text-xs">Contract Coverage</TabsTrigger>
<TabsTrigger value="mismatches" className="text-xs">
Mismatches
{(compliance.summary.contractedNotBackedUp + compliance.summary.backedUpNotContracted) > 0 && (
<Badge variant="destructive" className="ml-1.5 h-4 px-1 text-[10px]">
{compliance.summary.contractedNotBackedUp + compliance.summary.backedUpNotContracted}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="coverage">
<ContractCoverageTable />
</TabsContent>
<TabsContent value="mismatches">
<ComplianceDetailTable mismatches={compliance.mismatches} />
</TabsContent>
</Tabs>
</>
)}
</TabsContent>

1297
app/engagement/page.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -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<string, DayData> = {};
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<Array<{ date: string; inRange: boolean }>> = [];
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 (
<div className="overflow-x-auto pb-2">
<div className="inline-flex gap-2 min-w-0">
{/* Day labels */}
<div className="flex flex-col gap-[3px] pt-5 shrink-0">
{DAY_LABELS.map((label, i) => (
<div key={i} className="h-[13px] flex items-center">
<span className="text-[10px] text-muted-foreground w-6 text-right pr-1 leading-none">{label}</span>
</div>
))}
</div>
{/* Grid */}
<div className="flex flex-col">
{/* Month labels */}
<div className="flex mb-1 h-4 relative">
{weeks.map((_, wi) => {
const ml = monthLabels.find(m => m.weekIndex === wi);
return (
<div key={wi} className="w-[13px] shrink-0 relative">
{ml && (
<span className="absolute text-[10px] text-muted-foreground whitespace-nowrap leading-none">
{ml.label}
</span>
)}
</div>
);
})}
</div>
{/* Cells */}
<div className="flex gap-[3px]">
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col gap-[3px]">
{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 (
<div
key={di}
className={cn(
'w-[13px] h-[13px] rounded-[2px]',
isFuture ? 'opacity-0' : HEAT_CLASSES[level]
)}
title={
cell.inRange && !isFuture
? `${cell.date}: ${hours.toFixed(1)}h (${(data?.billableHours ?? 0).toFixed(1)}h billable)`
: cell.date
}
/>
);
})}
</div>
))}
</div>
</div>
</div>
{/* Legend */}
<div className="flex items-center gap-1.5 mt-3 ml-8">
<span className="text-[10px] text-muted-foreground">Less</span>
{HEAT_CLASSES.map((cls, i) => (
<div key={i} className={cn('w-[13px] h-[13px] rounded-[2px]', cls)} />
))}
<span className="text-[10px] text-muted-foreground">More</span>
</div>
</div>
);
}
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<UserOption[]>([]);
const [usersLoading, setUsersLoading] = useState(true);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [history, setHistory] = useState<HistoryData | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
const [backfill, setBackfill] = useState<BackfillStatus | null>(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 (
<div className="container mx-auto px-6 py-8 space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Employee Profile</h1>
<p className="text-muted-foreground text-sm mt-1">12-month activity overview</p>
</div>
<Select value={selectedUserId} onValueChange={handleSelect} disabled={usersLoading}>
<SelectTrigger className="w-64">
<SelectValue placeholder={usersLoading ? 'Loading…' : 'Select employee…'} />
</SelectTrigger>
<SelectContent>
{users.map(u => (
<SelectItem key={u.graphUserId} value={u.graphUserId}>
<div className="flex flex-col items-start">
<span>{u.displayName}</span>
{u.jobTitle && (
<span className="text-xs text-muted-foreground">{u.jobTitle}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Backfill panel */}
{backfill && (backfill.running || backfill.done) ? (
<Card className={cn('border', backfill.running ? 'border-blue-400' : backfill.errors > 0 ? 'border-yellow-400' : 'border-green-400')}>
<CardContent className="py-3 px-4 space-y-2">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm font-medium">
{backfill.running && <RefreshCw className="h-4 w-4 animate-spin text-blue-500" />}
{backfill.running
? `Backfilling meetings… ${backfill.processed}/${backfill.total} users`
: `Backfill complete — ${backfill.processed} users, ${backfill.errors} errors`}
</div>
{backfill.running && backfill.currentUser && (
<span className="text-xs text-muted-foreground truncate">{backfill.currentUser}</span>
)}
</div>
{backfill.running && backfill.total > 0 && (
<div className="w-full bg-muted rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all"
style={{ width: `${Math.round((backfill.processed / backfill.total) * 100)}%` }}
/>
</div>
)}
{backfill.log.length > 0 && (
<pre className="text-[10px] text-muted-foreground max-h-24 overflow-y-auto bg-muted/30 rounded p-2">
{backfill.log.slice(-20).join('\n')}
</pre>
)}
</CardContent>
</Card>
) : (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={startBackfill}
disabled={backfillStarting}
className="gap-2"
>
<RefreshCw className={cn('h-3.5 w-3.5', backfillStarting && 'animate-spin')} />
Backfill 12 months of meetings
</Button>
</div>
)}
{!selectedUserId && (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
<Users className="h-10 w-10 text-muted-foreground mb-4" />
<p className="font-medium">Select an employee</p>
<p className="text-sm text-muted-foreground mt-1">
Choose an employee above to see their 12-month activity profile
</p>
</CardContent>
</Card>
)}
{selectedUserId && historyLoading && (
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-48" />
))}
</div>
)}
{selectedUserId && !historyLoading && history && (
<>
{/* User header */}
<div>
<h2 className="text-xl font-semibold">{history.user.displayName}</h2>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-0.5 text-sm text-muted-foreground">
{history.user.jobTitle && <span>{history.user.jobTitle}</span>}
{history.user.jobTitle && history.user.department && <span>·</span>}
{history.user.department && <span>{history.user.department}</span>}
{(history.user.jobTitle || history.user.department) && <span>·</span>}
<span>{history.user.email}</span>
</div>
</div>
{/* Year stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Total Hours</p>
<p className="text-2xl font-bold tabular-nums">{totalHours.toFixed(0)}</p>
<p className="text-xs text-muted-foreground">over 12 months</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Billable Rate</p>
<p className="text-2xl font-bold tabular-nums">{billablePct}%</p>
<p className="text-xs text-muted-foreground">{totalBillable.toFixed(0)}h billable</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Avg hrs / month</p>
<p className="text-2xl font-bold tabular-nums">
{activeMonths > 0 ? (totalHours / activeMonths).toFixed(0) : '—'}
</p>
<p className="text-xs text-muted-foreground">{activeMonths} active months</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Peak Month</p>
<p className="text-2xl font-bold tabular-nums">
{peakMonth ? peakMonth.hoursWorked.toFixed(0) + 'h' : '—'}
</p>
<p className="text-xs text-muted-foreground">
{peakMonth ? monthLabel(peakMonth.month) : ''}
</p>
</CardContent>
</Card>
</div>
{/* Activity heatmap */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Activity Calendar</CardTitle>
<p className="text-xs text-muted-foreground">Daily hours worked last 12 months</p>
</CardHeader>
<CardContent>
{history.daily.length > 0 ? (
<ActivityHeatmap daily={history.daily} />
) : (
<p className="text-sm text-muted-foreground py-6 text-center">
No time entry data available
</p>
)}
</CardContent>
</Card>
{/* Monthly bar chart + radar */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="lg:col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Monthly Hours</CardTitle>
<p className="text-xs text-muted-foreground">Billable vs non-billable</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={barData} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value, name) => [
`${value}h`,
name === 'billable' ? 'Billable' : 'Non-billable',
]}
contentStyle={{ fontSize: 12 }}
/>
<Legend
formatter={v => (v === 'billable' ? 'Billable' : 'Non-billable')}
wrapperStyle={{ fontSize: 11 }}
/>
<Bar dataKey="billable" stackId="a" fill="#10b981" name="billable" />
<Bar dataKey="nonBillable" stackId="a" fill="#94a3b8" name="nonBillable" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Activity Signature</CardTitle>
<p className="text-xs text-muted-foreground">12-month average profile</p>
</CardHeader>
<CardContent className="flex items-center justify-center pt-0">
{radarData.length > 0 ? (
<RadarChart width={240} height={220} data={radarData}>
<PolarGrid />
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 10 }} />
<PolarRadiusAxis
angle={90}
domain={[0, 100]}
tick={false}
axisLine={false}
/>
<Radar
name="Profile"
dataKey="value"
stroke="#10b981"
fill="#10b981"
fillOpacity={0.3}
/>
</RadarChart>
) : (
<p className="text-sm text-muted-foreground text-center py-8">No data</p>
)}
</CardContent>
</Card>
</div>
{/* Monthly breakdown table */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Monthly Breakdown</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-muted-foreground uppercase tracking-wide">
<th className="text-left px-4 py-2.5 font-medium">Month</th>
<th className="text-right px-3 py-2.5 font-medium">Hours</th>
<th className="text-right px-3 py-2.5 font-medium">Billable</th>
<th className="text-right px-3 py-2.5 font-medium">Bill %</th>
<th className="text-right px-3 py-2.5 font-medium hidden sm:table-cell">Days</th>
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Meetings</th>
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Messages</th>
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Emails</th>
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Calls</th>
</tr>
</thead>
<tbody>
{[...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 (
<tr
key={m.month}
className={cn(
'border-b last:border-0 hover:bg-muted/30 transition-colors',
isEmpty && 'opacity-40'
)}
>
<td className="px-4 py-2 font-medium">{monthLabel(m.month)}</td>
<td className="px-3 py-2 text-right tabular-nums">
{m.hoursWorked > 0 ? m.hoursWorked.toFixed(1) : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-600 dark:text-emerald-400">
{m.billableHours > 0 ? m.billableHours.toFixed(1) : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{m.hoursWorked > 0 ? `${pct}%` : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums hidden sm:table-cell">
{m.daysWorked > 0 ? m.daysWorked : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
{m.totalMeetings > 0 ? m.totalMeetings : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
{m.teamsMessages > 0 ? m.teamsMessages : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
{m.emailsSent > 0 ? m.emailsSent : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
{totalCalls > 0 ? totalCalls : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</>
)}
</div>
);
}

View file

@ -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 {