- 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
456 lines
19 KiB
TypeScript
456 lines
19 KiB
TypeScript
'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>
|
||
);
|
||
}
|