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>
207 lines
7.1 KiB
TypeScript
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
|
|
);
|
|
}
|