- itglue/applications, applications/[id], configurations, configurations/[id], sites/[companyId], queue, ticket/[ticketNumber], tickets, reports, reports/[id]: useUserTimezone() in default export; thread tz into every inline toLocale*String call. - analyzer/tickets/page.tsx converts module-scope formatRelative(iso) helper to formatRelative(iso, tz); updates 1 callsite. Migrates 16 of 81 audit leak callsites.
154 lines
5.6 KiB
TypeScript
154 lines
5.6 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react';
|
|
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
|
|
|
interface ReportSummary {
|
|
id: string;
|
|
generatedAt: string;
|
|
reportTitle: string | null;
|
|
ticketCount: number;
|
|
status: 'pending' | 'running' | 'complete' | 'failed';
|
|
estimatedCostUsd: number | null;
|
|
modelUsed: string | null;
|
|
generatedByUserId: string | null;
|
|
}
|
|
|
|
export default function ReportsListPage() {
|
|
const tz = useUserTimezone();
|
|
const [reports, setReports] = useState<ReportSummary[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
fetch('/api/analyzer/aggregate-reports')
|
|
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
|
|
.then((data: { reports: ReportSummary[] }) => {
|
|
if (!cancelled) setReports(data.reports);
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled)
|
|
setError(err instanceof Error ? err.message : 'Failed to load reports');
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-6 space-y-6 max-w-screen-xl">
|
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
Aggregate reports
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm mt-1">
|
|
Cross-ticket pattern analysis. Generate a new one from the browse
|
|
page.
|
|
</p>
|
|
</div>
|
|
<Button asChild>
|
|
<Link href="/analyzer/tickets">Browse tickets</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
{loading ? (
|
|
<div className="p-6 text-sm text-muted-foreground">Loading…</div>
|
|
) : error ? (
|
|
<div className="p-6 text-sm text-destructive">{error}</div>
|
|
) : reports.length === 0 ? (
|
|
<div className="p-12 text-center text-muted-foreground">
|
|
<p>No reports yet.</p>
|
|
<p className="text-xs mt-1">
|
|
Pick tickets on the browse page and click{' '}
|
|
<em>Generate aggregate report</em>.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[110px]">Status</TableHead>
|
|
<TableHead>Title</TableHead>
|
|
<TableHead className="w-[100px]">Tickets</TableHead>
|
|
<TableHead className="w-[120px]">Model</TableHead>
|
|
<TableHead className="w-[100px]">Cost</TableHead>
|
|
<TableHead className="w-[160px]">Generated</TableHead>
|
|
<TableHead className="w-[100px] text-right">Action</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{reports.map((r) => (
|
|
<TableRow key={r.id}>
|
|
<TableCell>
|
|
{r.status === 'complete' ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
|
|
>
|
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
|
Complete
|
|
</Badge>
|
|
) : r.status === 'failed' ? (
|
|
<Badge variant="destructive">
|
|
<XCircle className="w-3 h-3 mr-1" />
|
|
Failed
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="secondary">
|
|
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
|
{r.status}
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{r.reportTitle ?? (
|
|
<span className="text-muted-foreground italic">Untitled</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-sm">{r.ticketCount}</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{r.modelUsed ?? '—'}
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
{r.estimatedCostUsd === null
|
|
? '—'
|
|
: `$${r.estimatedCostUsd.toFixed(4)}`}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{new Date(r.generatedAt).toLocaleString(undefined, { timeZone: tz })}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link href={`/analyzer/reports/${r.id}`}>View</Link>
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|