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:
parent
19605f82aa
commit
c518eefdb2
61 changed files with 11236 additions and 237 deletions
467
app/admin/morning-summary/page.tsx
Normal file
467
app/admin/morning-summary/page.tsx
Normal 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 Mon–Fri · 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 Mon–Fri)</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
85
app/api/data/contracts/[id]/services/route.ts
Normal file
85
app/api/data/contracts/[id]/services/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
186
app/api/engagement/backfill-meetings/route.ts
Normal file
186
app/api/engagement/backfill-meetings/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
151
app/api/engagement/summary/route.ts
Normal file
151
app/api/engagement/summary/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
30
app/api/engagement/sync/route.ts
Normal file
30
app/api/engagement/sync/route.ts
Normal 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() });
|
||||
}
|
||||
227
app/api/engagement/user/[userId]/history/route.ts
Normal file
227
app/api/engagement/user/[userId]/history/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
581
app/api/engagement/user/[userId]/route.ts
Normal file
581
app/api/engagement/user/[userId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
191
app/api/engagement/users/route.ts
Normal file
191
app/api/engagement/users/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
25
app/api/notifications/morning-summary/config/route.ts
Normal file
25
app/api/notifications/morning-summary/config/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
14
app/api/notifications/morning-summary/history/route.ts
Normal file
14
app/api/notifications/morning-summary/history/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
23
app/api/notifications/morning-summary/send/route.ts
Normal file
23
app/api/notifications/morning-summary/send/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
21
app/api/notifications/morning-summary/test/route.ts
Normal file
21
app/api/notifications/morning-summary/test/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
33
app/api/notifications/morning-summary/webhooks/[id]/route.ts
Normal file
33
app/api/notifications/morning-summary/webhooks/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
app/api/notifications/morning-summary/webhooks/route.ts
Normal file
31
app/api/notifications/morning-summary/webhooks/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
`);
|
||||
|
||||
|
|
|
|||
181
app/api/veeam/contract-coverage/route.ts
Normal file
181
app/api/veeam/contract-coverage/route.ts
Normal 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: [] });
|
||||
}
|
||||
}
|
||||
126
app/api/zabbix/create-host/route.ts
Normal file
126
app/api/zabbix/create-host/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
142
app/api/zabbix/hosts/[hostid]/route.ts
Normal file
142
app/api/zabbix/hosts/[hostid]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
68
app/api/zabbix/hosts/route.ts
Normal file
68
app/api/zabbix/hosts/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
21
app/api/zabbix/public-ip/route.ts
Normal file
21
app/api/zabbix/public-ip/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
46
app/api/zoom/sync/route.ts
Normal file
46
app/api/zoom/sync/route.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
|
|
@ -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
1297
app/engagement/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
648
app/engagement/profile/page.tsx
Normal file
648
app/engagement/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue