Major UI refresh on the nav-design-improvements branch. Drops 2013-era
inline styles and consolidates patterns behind shared primitives.
Foundation
- New Wulf brand layer in app/styles/brand.css repointing --primary to
the standards-guide blue (#0075AD) with utility classes for numerics
(.num / .num-lg / .num-xl), metric labels, surface tints, and the
wolf-mark watermark
- Switch primary face to IBM Plex Sans + IBM Plex Mono via next/font;
Helvetica/Arial stays in the fallback chain for brand fidelity
- Wordmark subtitle changed from "PSA Management System" to
"Operations console" everywhere it appeared
- Tagline footer ("Don't be afraid to cry") on every non-mobile page
Status moved out of /dashboard
- New /status route with integration tiles grouped by category, sync
health table, worker pulse cards (analyzer / RMM / sync scheduler),
token-expiry section, conditional alert banner
- Top-bar StatusIndicator polls integration health every 60s and links
to /status
- INTEGRATIONS_DISABLED env var suppresses operator-disabled
integrations (e.g. SentinelOne) — no failure noise from broken-on-
purpose entries. Aliases supported (sentinelone → s1, etc.)
Dashboard rebuilt around KPIs
- /api/dashboard/overview adds today snapshot (opened, resolved, open
total, SLA breaches) with delta math
- /api/dashboard/trends backs queue × priority heatmap, 30-day volume
area chart, 30-day mean resolution time line chart, today's active
engineers leaderboard
Components
- StatusBadge driven by lib/status-registry.ts (priority, ticket
status, classification, source, company type, publish, active /
yes-no / billable / approved registries)
- StatusLight (8px geometric square, five states, three sizes)
- EmptyState (shared dashed panel with icon + headline + optional CTA)
- KpiCard with delta indicator and tonal left border
- WulfMark (mark / wordmark variants from /public/branding)
- Skeleton helpers (SkeletonRow / Rows / Card / Chart / Header / Table)
Navigation
- Admin flat link → dropdown with seven shortcuts
- New UserMenu (initials avatar, role badge, settings + sign-out)
- Active-route highlight is now a 2px Wulf-blue underline echoing the
PageHeader rule (consistent across flat links and submenu triggers);
active children inside dropdowns use bg-primary/10
- Submenu width is content-driven (min-w 320 / max-w 440, single col)
- Mobile hamburger via Sheet, reuses the same nav config
Pages migrated
- 16 admin sub-pages adopt PageHeader (with accent prop)
- /addigy-devices: shadcn Table + Checkbox; PageHeader; status badges
- 10 raw <table> blocks across admin/sync/* migrated to shadcn Table
- /veeam-analysis migrated to shadcn Table (kept its expansion logic)
- Detail routes (analyzer ticket, analyzer analysis) get breadcrumbs
DataTable
- Rewritten on @tanstack/react-table v8 in manual mode; external API
unchanged so all 10+ data-browser pages keep working
- New optional props for drill-down rows: getRowCanExpand + renderSubRow
Mobile
- Multi-select Popover gets max-w-[calc(100vw-1rem)] and
collisionPadding so dropdowns can't overflow narrow viewports
- CI filter bar wraps and shrinks; stat pill flows below
Docs
- New ARCHITECTURE.md (load-bearing reference for runtime, data flow,
workers, analyzer pipeline, auth, deployment, gotchas)
- New DESIGN.md (tokens, layout, navigation IA, component vocabulary,
rolling backlog of remaining cleanup)
- CLAUDE.md refreshed with pointers to the two new docs and the
INTEGRATIONS_DISABLED operator config note
- shadcn registry registered as project-level MCP server (.mcp.json)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
455 lines
19 KiB
TypeScript
455 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';
|
||
import { PageHeader } from '@/components/navigation/page-header';
|
||
|
||
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 (
|
||
<>
|
||
<PageHeader
|
||
title="Ticket Digest Reports"
|
||
description="LLM-analyzed ticket reports delivered to Teams — daily, weekly, and monthly"
|
||
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Ticket Digest Reports' }]}
|
||
accent
|
||
actions={
|
||
<Button variant="outline" size="sm" onClick={loadData}>
|
||
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
|
||
</Button>
|
||
}
|
||
/>
|
||
<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>
|
||
)}
|
||
|
||
{/* 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>
|
||
</>
|
||
);
|
||
}
|