Files
pn-new-crm/src/components/admin/reports-dashboard.tsx
Matt Ciaccio c90876abad feat(admin): inquiry inbox, send log, email-template overrides, reports dashboard, recommender keys, role-editor coverage; replace placeholder pages
Closes the bulk of audit-pass-#1 admin gaps in one batch.

New admin pages:
- /admin/inquiries reads website_submissions with filter chips for
  berth/residence/contact + payload viewer per row.
- /admin/sends reads document_sends with sent/failed filter chips and
  expandable body markdown; failures surface errorReason and any
  fallback-to-link reason from the SMTP retry.
- /admin/email-templates lets per-port admins override the subject of
  each transactional template (8 templates catalogued in
  template-catalog.ts). Body editing is a follow-on; portal_activation
  + portal_reset are wired to honor the override via loadSubjectOverride.
- /admin/reports replaces the "Coming in Layer 3" placeholder with a
  KPI dashboard: 4 KPI tiles, pipeline funnel bars, berth occupancy
  donut-bars, conversion %, refresh every 60s.
- backup/import/onboarding admin pages replace placeholders with
  actionable guidance: backup posture + planned features, available CLI
  imports + planned UI, ordered onboarding checklist linking to admin
  pages.

Existing pages widened:
- settings-manager exposes the 9 berth-recommender tunables that were
  previously code-only (recommender_*, heat_weight_*, fallthrough_*,
  tier_ladder_hide_late_stage).
- role-form covers all 19 RolePermissions schema groups; previously
  missing yachts/companies/memberships/reservations + missing
  documents.edit + files.edit checkboxes. snake_case residential
  labels replaced with friendly text.

portal-auth.service.ts now also writes audit_log rows for portal
invite, resend, activate, password-reset request, and reset (closes one
more audit-pass-#2 gap while we were touching the file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:58:17 +02:00

207 lines
7.1 KiB
TypeScript

'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES, STAGE_LABELS, type PipelineStage } from '@/lib/constants';
interface DashboardStats {
totals: { totalClients: number; totalInterests: number; totalBerths: number };
recent: { newInquiries7d: number; completed30d: number };
pipeline: Record<string, number>;
berthStatus: { available: number; under_offer: number; sold: number };
conversion: { closedTotal: number; openTotal: number; conversionPct: number };
}
const BERTH_STATUS_COLORS: Record<string, string> = {
available: 'bg-green-500',
under_offer: 'bg-amber-500',
sold: 'bg-slate-500',
};
const BERTH_STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under offer',
sold: 'Sold',
};
export function ReportsDashboard() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading, error } = useQuery({
queryKey: ['admin-dashboard-stats'],
queryFn: () => apiFetch<{ data: DashboardStats }>('/api/v1/admin/dashboard-stats'),
refetchInterval: 60_000,
});
if (isLoading) {
return (
<div>
<PageHeader title="Reports" description="Pipeline, occupancy, and recent activity." />
<p className="text-sm text-muted-foreground py-6">Loading</p>
</div>
);
}
if (error || !data) {
return (
<div>
<PageHeader title="Reports" description="Pipeline, occupancy, and recent activity." />
<p className="text-sm text-red-600 py-6">
Failed to load stats: {error instanceof Error ? error.message : 'unknown error'}
</p>
</div>
);
}
const stats = data.data;
const maxStageCount = Math.max(1, ...Object.values(stats.pipeline));
const totalBerths =
stats.berthStatus.available + stats.berthStatus.under_offer + stats.berthStatus.sold;
return (
<div>
<PageHeader
title="Reports"
description="Live snapshot of clients, pipeline, and berth occupancy. Refreshes every minute."
/>
{/* KPI tiles */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-6">
<KpiTile label="Clients" value={stats.totals.totalClients} href={`/${portSlug}/clients`} />
<KpiTile
label="Open interests"
value={stats.conversion.openTotal}
href={`/${portSlug}/interests`}
/>
<KpiTile
label="Inquiries (7 days)"
value={stats.recent.newInquiries7d}
href={`/${portSlug}/admin/inquiries`}
/>
<KpiTile
label="Completed (30 days)"
value={stats.recent.completed30d}
accent={stats.recent.completed30d > 0 ? 'success' : undefined}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
{/* Pipeline funnel */}
<Card>
<CardHeader>
<CardTitle>Pipeline funnel</CardTitle>
<CardDescription>Open interests by stage (excludes archived).</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{PIPELINE_STAGES.map((stage) => {
const n = stats.pipeline[stage] ?? 0;
const pct = (n / maxStageCount) * 100;
return (
<li key={stage} className="text-sm">
<div className="flex items-center justify-between mb-1">
<span>{STAGE_LABELS[stage as PipelineStage]}</span>
<span className="font-medium">{n}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
<div className="mt-4 pt-4 border-t text-sm">
<span className="text-muted-foreground">Conversion (completed / total):</span>{' '}
<span className="font-medium">{stats.conversion.conversionPct}%</span>
</div>
</CardContent>
</Card>
{/* Berth occupancy */}
<Card>
<CardHeader>
<CardTitle>Berth occupancy</CardTitle>
<CardDescription>
Current public-status mix for {totalBerths} berths in this port.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{(['available', 'under_offer', 'sold'] as const).map((status) => {
const n = stats.berthStatus[status];
const pct = totalBerths === 0 ? 0 : Math.round((n / totalBerths) * 100);
return (
<div key={status} className="text-sm">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${BERTH_STATUS_COLORS[status]}`}
/>
<span>{BERTH_STATUS_LABELS[status]}</span>
</div>
<span className="font-medium">
{n} <span className="text-muted-foreground">· {pct}%</span>
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full ${BERTH_STATUS_COLORS[status]}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
<p className="text-xs text-muted-foreground mt-6">
Need scheduled or downloadable reports?{' '}
<Link href={`/${portSlug}/reports` as never} className="underline">
Open the report generator
</Link>{' '}
to produce PDF exports of these views.
</p>
</div>
);
}
function KpiTile({
label,
value,
href,
accent,
}: {
label: string;
value: number;
href?: string;
accent?: 'success' | 'danger';
}) {
const accentClass =
accent === 'success' ? 'text-green-700' : accent === 'danger' ? 'text-red-700' : '';
const inner = (
<Card className="h-full transition hover:border-primary/40">
<CardContent className="py-5">
<div className={`text-3xl font-semibold ${accentClass}`}>{value}</div>
<div className="text-sm text-muted-foreground mt-1">{label}</div>
</CardContent>
</Card>
);
return href ? (
<Link href={href as never} className="block">
{inner}
</Link>
) : (
inner
);
}