Files
pn-new-crm/src/components/reports/sub-pages/report-runs-page-client.tsx
Matt 05950ae0b6
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m42s
Build & Push Docker Images / build-and-push (push) Successful in 7m20s
feat(uat): file preview/download fix, clients-by-country page, residential column picker
Batch #4 UAT items.

1. Documents — clicking any file dumped raw presigned-URL JSON. Was
   systemic: 6 surfaces linked a browser directly at the JSON-returning
   /files/[id]/{download,preview} routes. Those routes now 302-redirect
   when called with ?redirect=1 (default stays JSON for the dialog +
   interest-eoi-tab programmatic consumers); the six <Link> sites use it.
   The documents-hub file row now opens the inline FilePreviewDialog +
   has a per-row Download button, and the preview dialog header gained a
   persistent Download button for all file types.

2. Clients-by-country — the widget's "+N more" dead text is now a
   "Show all" link to a new /clients/by-country page rendering the full
   ranked country breakdown (each row drills into the filtered list).

3. Residential clients list — moved off its bespoke table onto the
   shared DataTable + ColumnPicker (same UX as clients/interests). Adds
   a "Date added" column, default-hides the empty "Residence" column,
   preserves the mobile card view, persists per-user column choices.

tsc clean, eslint clean, 1584/1584 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:34:47 +02:00

163 lines
5.8 KiB
TypeScript

'use client';
import Link from 'next/link';
import type { Route } from 'next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Download, Mail, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { EmptyState } from '@/components/shared/empty-state';
import { apiFetch } from '@/lib/api/client';
import type { ReportRun } from '@/lib/db/schema/reports';
interface ListResponse {
data: ReportRun[];
total: number;
hasMore: boolean;
}
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
pending: 'secondary',
rendering: 'secondary',
complete: 'default',
failed: 'destructive',
};
export function ReportRunsPageClient({ portSlug }: { portSlug: string }) {
const qc = useQueryClient();
const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['report-runs'],
queryFn: () => apiFetch<ListResponse>('/api/v1/reports/runs?limit=50&order=desc'),
refetchInterval: (query) => {
// Auto-poll while any row is in flight so the rep sees status flip
// without manual refresh.
const rows = query.state.data?.data ?? [];
return rows.some((r) => r.status === 'pending' || r.status === 'rendering') ? 5_000 : false;
},
});
const rerunMutation = useMutation({
mutationFn: async (run: ReportRun) => {
return apiFetch('/api/v1/reports/runs', {
method: 'POST',
body: {
kind: run.kind,
config: run.config,
outputFormat: run.outputFormat,
...(run.templateId ? { templateId: run.templateId } : {}),
},
});
},
onSuccess: () => {
toast.success('Re-run queued');
qc.invalidateQueries({ queryKey: ['report-runs'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Re-run failed'),
});
const rows = data?.data ?? [];
return (
<div className="space-y-4">
<PageHeader
eyebrow="Reports"
title="Runs"
description="Every report generated for this port. In-flight runs auto-refresh."
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/${portSlug}/reports` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" aria-hidden />
All reports
</Link>
</Button>
}
/>
{isLoading ? (
<Skeleton className="h-[200px] w-full" aria-hidden />
) : rows.length === 0 ? (
<EmptyState
title="No runs yet"
description="Generate a report from the Reports landing page to see it here."
/>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Kind</TableHead>
<TableHead>Status</TableHead>
<TableHead>Triggered</TableHead>
<TableHead>Created</TableHead>
<TableHead>Output</TableHead>
<TableHead className="w-32 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium capitalize">{r.kind}</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANT[r.status] ?? 'outline'}>{r.status}</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground capitalize">
{r.triggeredBy}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(r.createdAt).toLocaleString()}
</TableCell>
<TableCell className="text-xs uppercase tracking-wide text-muted-foreground">
{r.outputFormat}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{r.status === 'complete' && r.storageKey ? (
<Button asChild size="sm" variant="ghost" title="Download artefact">
<Link
href={`/api/v1/files/${r.storageKey}/download?redirect=1` as Route}
>
<Download className="h-3.5 w-3.5" aria-hidden />
</Link>
</Button>
) : null}
<Button
size="sm"
variant="ghost"
title="Re-run with the same config"
onClick={() => rerunMutation.mutate(r)}
disabled={rerunMutation.isPending}
>
{rerunMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Mail className="h-3.5 w-3.5" aria-hidden />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}