wulf-pulse/app/admin/ticket-digest/page.tsx
lorentz b98c67482a feat: QuickBooks Online integration
- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts)
- Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts)
- Add QBO types (lib/types/qbo.ts)
- Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect
- Add /admin/qbo status and sync management page
- Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment)
- Add QBO nav link under Admin
- Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all
- Add CashFlow report type alongside P&L and BalanceSheet
- Add NoReportData check to skip empty report months
- Add intuit_tid capture in error messages
- Add redirect: follow for cluster routing
- Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables

Also includes earlier work:
- Ping flap suppression pipeline step
- Ticket digest reports with LLM analysis
- Zabbix WAN monitor and gap analysis
- Kiosk is_deleted filter fixes
- Datto RMM ping target enrichment
- Entity sync soft-delete detection
2026-03-17 07:39:55 -04:00

456 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
Send, RefreshCw, CheckCircle2, XCircle,
Clock, Loader2, ChevronDown, ChevronUp, BarChart3, Brain,
Calendar, CalendarDays, CalendarRange, MessageSquare, Bell, Globe, ExternalLink,
} from 'lucide-react';
interface DigestConfig {
daily_enabled: boolean;
weekly_enabled: boolean;
monthly_enabled: boolean;
daily_cron: string;
weekly_cron: string;
monthly_cron: string;
llm_provider: string;
llm_model: string;
include_noise_analysis: boolean;
include_sla_analysis: boolean;
include_resource_analysis: boolean;
include_client_analysis: boolean;
include_recommendations: boolean;
channel_ids: number[];
}
interface NotificationChannel {
id: number;
name: string;
channel_type: 'teams' | 'telegram' | 'ntfy' | 'webhook';
config: Record<string, any>;
is_active: boolean;
}
interface DigestReport {
id: number;
period_type: string;
period_start: string;
period_end: string;
generated_at: string;
stats: any;
llm_analysis: string | null;
delivery_status: Record<string, { success: boolean; httpStatus?: number; error?: string }>;
tokens_used: number | null;
processing_time_ms: number | null;
}
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 ChannelIcon({ type }: { type: string }) {
if (type === 'teams') return <MessageSquare className="h-4 w-4 text-indigo-500" />;
if (type === 'telegram') return <Send className="h-4 w-4 text-blue-500" />;
if (type === 'ntfy') return <Bell className="h-4 w-4 text-green-500" />;
return <Globe className="h-4 w-4 text-gray-500" />;
}
function PeriodIcon({ period }: { period: string }) {
if (period === 'daily') return <Calendar className="h-4 w-4 text-blue-500" />;
if (period === 'weekly') return <CalendarDays className="h-4 w-4 text-purple-500" />;
return <CalendarRange className="h-4 w-4 text-orange-500" />;
}
export default function TicketDigestPage() {
const [config, setConfig] = useState<DigestConfig | null>(null);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [history, setHistory] = useState<DigestReport[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
const [expandedReport, setExpandedReport] = useState<number | null>(null);
const [previewData, setPreviewData] = useState<any>(null);
const [previewPeriod, setPreviewPeriod] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const showToast = useCallback((msg: string, ok: boolean) => {
setToast({ msg, ok });
setTimeout(() => setToast(null), 4000);
}, []);
const loadData = useCallback(async () => {
try {
const res = await fetch('/api/reports/ticket-digest');
const data = await res.json();
setConfig(data.config);
setChannels(data.channels || []);
setHistory(data.history || []);
} catch (e) {
showToast('Failed to load data', false);
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => { loadData(); }, [loadData]);
const generateReport = async (period: string) => {
setGenerating(period);
try {
const res = await fetch('/api/reports/ticket-digest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ period }),
});
const data = await res.json();
if (data.success) {
showToast(`${period} digest generated (${data.processingTimeMs}ms)`, true);
loadData();
} else {
showToast(data.error || 'Generation failed', false);
}
} catch (e) {
showToast('Network error', false);
} finally {
setGenerating(null);
}
};
const loadPreview = async (period: string) => {
if (previewPeriod === period) { setPreviewPeriod(null); setPreviewData(null); return; }
setPreviewLoading(true);
setPreviewPeriod(period);
try {
const res = await fetch(`/api/reports/ticket-digest?preview=${period}`);
const data = await res.json();
setPreviewData(data.stats);
} catch {
showToast('Failed to load preview', false);
} finally {
setPreviewLoading(false);
}
};
const updateConfig = async (updates: Partial<DigestConfig>) => {
try {
const res = await fetch('/api/reports/ticket-digest/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
const data = await res.json();
setConfig(data.config);
showToast('Config updated', true);
} catch {
showToast('Failed to update config', false);
}
};
const toggleChannelId = async (id: number) => {
if (!config) return;
const current = config.channel_ids || [];
const updated = current.includes(id)
? current.filter(c => c !== id)
: [...current, id];
await updateConfig({ channel_ids: updated });
};
if (loading) return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
return (
<div className="container mx-auto py-8 px-4 max-w-4xl space-y-8">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg text-sm text-white ${toast.ok ? 'bg-green-600' : 'bg-red-600'}`}>
{toast.msg}
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<BarChart3 className="h-6 w-6" /> Ticket Digest Reports
</h1>
<p className="text-muted-foreground text-sm mt-1">
LLM-analyzed ticket reports delivered to Teams daily, weekly, and monthly
</p>
</div>
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
</Button>
</div>
{/* Generate Reports */}
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg flex items-center gap-2">
<Brain className="h-5 w-5" /> Generate Report
</h2>
<div className="grid grid-cols-3 gap-3">
{(['daily', 'weekly', 'monthly'] as const).map(p => (
<div key={p} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<PeriodIcon period={p} />
<span className="font-medium capitalize">{p}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => generateReport(p)}
disabled={!!generating}
className="flex-1"
>
{generating === p ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Send className="h-4 w-4 mr-1" />}
Generate & Send
</Button>
<Button
size="sm"
variant="outline"
onClick={() => loadPreview(p)}
disabled={previewLoading}
>
<BarChart3 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{/* Preview */}
{previewPeriod && previewData && (
<div className="border rounded-lg p-4 bg-muted/30 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold capitalize">{previewPeriod} Preview {previewData.period?.label}</h3>
<Button size="sm" variant="ghost" onClick={() => { setPreviewPeriod(null); setPreviewData(null); }}>
<XCircle className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 gap-3 text-center">
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.total_created ?? 0}</div>
<div className="text-xs text-muted-foreground">Created</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.total_resolved ?? 0}</div>
<div className="text-xs text-muted-foreground">Resolved</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.avg_resolution_hours ?? '—'}h</div>
<div className="text-xs text-muted-foreground">Avg Resolve</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{(previewData.overview?.total_hours_worked ?? 0).toFixed(1)}h</div>
<div className="text-xs text-muted-foreground">Hours Worked</div>
</div>
</div>
{previewData.noise_candidates?.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-1">🔁 Noise Candidates ({previewData.noise_candidates.length})</h4>
<div className="text-xs space-y-1 max-h-40 overflow-y-auto">
{previewData.noise_candidates.slice(0, 10).map((n: any, i: number) => (
<div key={i} className="flex justify-between bg-background rounded px-2 py-1">
<span className="truncate">{n.title}</span>
<span className="text-muted-foreground shrink-0 ml-2">{n.count}× · {n.source_label}</span>
</div>
))}
</div>
</div>
)}
{previewData.top_clients?.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-1">🏢 Top Clients</h4>
<div className="text-xs space-y-1">
{previewData.top_clients.slice(0, 5).map((c: any, i: number) => (
<div key={i} className="flex justify-between bg-background rounded px-2 py-1">
<span>{c.company_name}</span>
<span className="text-muted-foreground">{c.ticket_count} tickets · {c.hours_worked.toFixed(1)}h</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Analysis Sections Config */}
{config && (
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg">Analysis Sections</h2>
<div className="grid grid-cols-2 gap-3">
{([
{ key: 'include_noise_analysis', label: '🔁 Noise & Automation' },
{ key: 'include_sla_analysis', label: '⏱️ SLA & Response Times' },
{ key: 'include_resource_analysis', label: '👥 Team Workload' },
{ key: 'include_client_analysis', label: '🏢 Client Spotlight' },
{ key: 'include_recommendations', label: '💡 Recommendations' },
] as const).map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 cursor-pointer p-2 rounded hover:bg-muted/50">
<input
type="checkbox"
checked={(config as any)[key]}
onChange={() => updateConfig({ [key]: !(config as any)[key] })}
className="rounded"
/>
<span className="text-sm">{label}</span>
</label>
))}
</div>
<div className="flex items-center gap-4 text-sm">
<label className="flex items-center gap-2">
Provider:
<select
value={config.llm_provider}
onChange={e => updateConfig({ llm_provider: e.target.value })}
className="border rounded px-2 py-1 bg-background"
>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
</select>
</label>
<label className="flex items-center gap-2">
Model:
<input
value={config.llm_model}
onChange={e => updateConfig({ llm_model: e.target.value })}
className="border rounded px-2 py-1 bg-background w-56"
/>
</label>
</div>
</div>
)}
{/* Notification Channels */}
<div className="border rounded-lg p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-lg">Delivery Channels</h2>
<p className="text-xs text-muted-foreground mt-0.5">Select which notification channels receive these reports. Manage channels in <a href="/admin/workflow/channels" className="underline">Notification Channels</a>.</p>
</div>
<a href="/admin/workflow/channels" className="text-xs text-muted-foreground flex items-center gap-1 hover:text-foreground">
<ExternalLink className="h-3 w-3" /> Manage Channels
</a>
</div>
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No notification channels configured yet.{' '}
<a href="/admin/workflow/channels" className="underline">Add one here.</a>
</p>
) : (
<div className="space-y-2">
{channels.map(ch => {
const selected = config?.channel_ids?.includes(ch.id) ?? false;
return (
<label
key={ch.id}
className={`flex items-center gap-3 border rounded-lg p-3 cursor-pointer transition-colors ${
selected ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'
} ${!ch.is_active ? 'opacity-50' : ''}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleChannelId(ch.id)}
className="rounded"
disabled={!ch.is_active}
/>
<ChannelIcon type={ch.channel_type} />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{ch.name}</div>
<div className="text-xs text-muted-foreground capitalize">{ch.channel_type}{!ch.is_active ? ' · Inactive' : ''}</div>
</div>
{selected && (
<span className="text-xs text-primary font-medium">Selected</span>
)}
</label>
);
})}
</div>
)}
{config && (config.channel_ids?.length ?? 0) === 0 && channels.length > 0 && (
<p className="text-xs text-amber-500"> No channels selected reports will be generated but not delivered.</p>
)}
</div>
{/* History */}
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg">Report History</h2>
{history.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No reports generated yet. Generate your first report above.</p>
) : (
<div className="space-y-2">
{history.map(report => {
const isExpanded = expandedReport === report.id;
const ov = report.stats?.overview;
return (
<div key={report.id} className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between p-3 hover:bg-muted/30 text-left"
onClick={() => setExpandedReport(isExpanded ? null : report.id)}
>
<div className="flex items-center gap-3">
<PeriodIcon period={report.period_type} />
<div>
<span className="font-medium text-sm capitalize">{report.period_type}</span>
<span className="text-xs text-muted-foreground ml-2">
{new Date(report.generated_at).toLocaleString()}
</span>
</div>
</div>
<div className="flex items-center gap-4 text-xs">
{ov && (
<span className="text-muted-foreground">
{ov.total_created} created · {ov.total_resolved} resolved
</span>
)}
{report.tokens_used && (
<span className="text-muted-foreground">{report.tokens_used} tokens</span>
)}
{report.processing_time_ms && (
<span className="text-muted-foreground">{(report.processing_time_ms / 1000).toFixed(1)}s</span>
)}
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
</button>
{isExpanded && report.llm_analysis && (
<div className="px-4 pb-4 border-t">
<div className="mt-3 prose prose-sm max-w-none dark:prose-invert text-sm whitespace-pre-wrap">
{report.llm_analysis}
</div>
{report.delivery_status && Object.keys(report.delivery_status).length > 0 && (
<div className="mt-3 border-t pt-2">
<span className="text-xs font-medium text-muted-foreground">Delivery:</span>
{Object.entries(report.delivery_status).map(([whId, st]) => (
<span key={whId} className={`text-xs ml-2 ${(st as any).success ? 'text-green-500' : 'text-red-500'}`}>
#{whId}: {(st as any).success ? 'OK' : (st as any).error || 'Failed'}
</span>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}