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>
This commit is contained in:
166
src/components/admin/email-templates-admin.tsx
Normal file
166
src/components/admin/email-templates-admin.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { RotateCcw, Save } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface TemplateRow {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
mergeTokens: string[];
|
||||
defaultSubject: string;
|
||||
subjectOverride: string | null;
|
||||
effectiveSubject: string;
|
||||
}
|
||||
|
||||
export function EmailTemplatesAdmin() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['admin-email-templates'],
|
||||
queryFn: () => apiFetch<{ data: TemplateRow[] }>('/api/v1/admin/email-templates'),
|
||||
});
|
||||
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ key: string; kind: 'ok' | 'err'; text: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||
|
||||
useEffect(() => {
|
||||
// Hydrate drafts from server values whenever the source-of-truth list refreshes.
|
||||
const next: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
next[row.key] = row.subjectOverride ?? row.defaultSubject;
|
||||
}
|
||||
setDrafts(next);
|
||||
}, [rows]);
|
||||
|
||||
async function save(row: TemplateRow, mode: 'save' | 'reset') {
|
||||
setSavingKey(row.key);
|
||||
setMessage(null);
|
||||
try {
|
||||
const subject = mode === 'reset' ? null : (drafts[row.key] ?? '');
|
||||
await apiFetch('/api/v1/admin/email-templates', {
|
||||
method: 'PUT',
|
||||
body: { key: row.key, subject },
|
||||
});
|
||||
await qc.invalidateQueries({ queryKey: ['admin-email-templates'] });
|
||||
setMessage({
|
||||
key: row.key,
|
||||
kind: 'ok',
|
||||
text: mode === 'reset' ? 'Reset to default' : 'Saved',
|
||||
});
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
key: row.key,
|
||||
kind: 'err',
|
||||
text: err instanceof Error ? err.message : 'Failed',
|
||||
});
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Email templates"
|
||||
description="Customize the subject line of transactional emails per port. Body editing is the next iteration; for now the layout and HTML stay locked to the default template."
|
||||
/>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground py-6">Loading…</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-red-600 py-6">
|
||||
Failed to load templates: {error instanceof Error ? error.message : 'unknown error'}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const draft = drafts[row.key] ?? row.defaultSubject;
|
||||
const dirty =
|
||||
draft !== (row.subjectOverride ?? row.defaultSubject) ||
|
||||
(row.subjectOverride !== null && draft === row.defaultSubject);
|
||||
const overridden = row.subjectOverride !== null;
|
||||
return (
|
||||
<Card key={row.key}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle className="text-base font-medium">{row.label}</CardTitle>
|
||||
{overridden ? (
|
||||
<Badge className="bg-blue-100 text-blue-800">Overridden</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Default</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>{row.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Subject
|
||||
</label>
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) =>
|
||||
setDrafts((prev) => ({ ...prev, [row.key]: e.target.value }))
|
||||
}
|
||||
className="mt-1 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Default: <code className="font-mono">{row.defaultSubject}</code>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Available tokens:{' '}
|
||||
{row.mergeTokens.map((t) => (
|
||||
<code key={t} className="mr-1 font-mono">{`{{${t}}}`}</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => save(row, 'save')}
|
||||
disabled={savingKey === row.key || !dirty}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5 mr-1.5" /> Save
|
||||
</Button>
|
||||
{overridden ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => save(row, 'reset')}
|
||||
disabled={savingKey === row.key}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1.5" /> Reset to default
|
||||
</Button>
|
||||
) : null}
|
||||
{message?.key === row.key ? (
|
||||
<span
|
||||
className={
|
||||
message.kind === 'ok' ? 'text-sm text-green-600' : 'text-sm text-red-600'
|
||||
}
|
||||
>
|
||||
{message.text}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/components/admin/inquiry-inbox.tsx
Normal file
206
src/components/admin/inquiry-inbox.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface Submission {
|
||||
id: string;
|
||||
portId: string;
|
||||
submissionId: string;
|
||||
kind: 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
|
||||
payload: Record<string, unknown> | null;
|
||||
legacyNocodbId: string | null;
|
||||
sourceIp: string | null;
|
||||
userAgent: string | null;
|
||||
receivedAt: string;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
data: Submission[];
|
||||
pagination: { nextCursor: { receivedAt: string; id: string } | null };
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<Submission['kind'], string> = {
|
||||
berth_inquiry: 'Berth inquiry',
|
||||
residence_inquiry: 'Residence inquiry',
|
||||
contact_form: 'Contact form',
|
||||
};
|
||||
|
||||
const KIND_COLORS: Record<Submission['kind'], string> = {
|
||||
berth_inquiry: 'bg-blue-100 text-blue-800',
|
||||
residence_inquiry: 'bg-amber-100 text-amber-800',
|
||||
contact_form: 'bg-slate-100 text-slate-800',
|
||||
};
|
||||
|
||||
function pickName(payload: Record<string, unknown> | null): string {
|
||||
if (!payload) return '';
|
||||
const candidates = ['name', 'fullName', 'full_name', 'firstName', 'first_name'];
|
||||
for (const k of candidates) {
|
||||
const v = payload[k];
|
||||
if (typeof v === 'string' && v.trim()) return v.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pickEmail(payload: Record<string, unknown> | null): string {
|
||||
if (!payload) return '';
|
||||
const v = payload['email'];
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
function pickPhone(payload: Record<string, unknown> | null): string {
|
||||
if (!payload) return '';
|
||||
const v = payload['phone'] ?? payload['phoneNumber'] ?? payload['phone_number'];
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
export function InquiryInbox() {
|
||||
const [kind, setKind] = useState<Submission['kind'] | 'all'>('all');
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['inquiry-inbox', kind],
|
||||
queryFn: () =>
|
||||
apiFetch<ListResponse>(
|
||||
`/api/v1/admin/website-submissions${kind === 'all' ? '' : `?kind=${kind}`}`,
|
||||
),
|
||||
});
|
||||
|
||||
const counts = data?.counts ?? {};
|
||||
const totalAll = useMemo(() => Object.values(counts).reduce((sum, n) => sum + n, 0), [counts]);
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Inquiry inbox"
|
||||
description="Submissions captured from the public marketing site (berth, residence, and contact forms)."
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 mt-6 flex-wrap">
|
||||
<FilterChip
|
||||
label={`All (${totalAll})`}
|
||||
active={kind === 'all'}
|
||||
onClick={() => setKind('all')}
|
||||
/>
|
||||
<FilterChip
|
||||
label={`Berth inquiries (${counts.berth_inquiry ?? 0})`}
|
||||
active={kind === 'berth_inquiry'}
|
||||
onClick={() => setKind('berth_inquiry')}
|
||||
/>
|
||||
<FilterChip
|
||||
label={`Residence (${counts.residence_inquiry ?? 0})`}
|
||||
active={kind === 'residence_inquiry'}
|
||||
onClick={() => setKind('residence_inquiry')}
|
||||
/>
|
||||
<FilterChip
|
||||
label={`Contact (${counts.contact_form ?? 0})`}
|
||||
active={kind === 'contact_form'}
|
||||
onClick={() => setKind('contact_form')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground py-6">Loading…</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-red-600 py-6">
|
||||
Failed to load inquiries: {error instanceof Error ? error.message : 'unknown error'}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
No website submissions yet for this filter.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const name = pickName(row.payload);
|
||||
const email = pickEmail(row.payload);
|
||||
const phone = pickPhone(row.payload);
|
||||
const ago = formatDistanceToNow(new Date(row.receivedAt), { addSuffix: true });
|
||||
const isOpen = expanded === row.id;
|
||||
return (
|
||||
<Card key={row.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={KIND_COLORS[row.kind]}>{KIND_LABELS[row.kind]}</Badge>
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
title={new Date(row.receivedAt).toISOString()}
|
||||
>
|
||||
{ago}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="mt-2 text-base font-medium">
|
||||
{name || '(no name supplied)'}
|
||||
</CardTitle>
|
||||
<div className="text-sm text-muted-foreground mt-1 space-x-3">
|
||||
{email ? <span>{email}</span> : null}
|
||||
{phone ? <span>{phone}</span> : null}
|
||||
{row.sourceIp ? (
|
||||
<span className="text-xs">from {row.sourceIp}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setExpanded(isOpen ? null : row.id)}
|
||||
>
|
||||
{isOpen ? 'Hide payload' : 'View payload'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isOpen && (
|
||||
<CardContent>
|
||||
<pre className="bg-muted/40 rounded-md p-3 text-xs overflow-auto max-h-96">
|
||||
{JSON.stringify(row.payload, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`px-3 py-1.5 rounded-full text-sm border transition ${
|
||||
active
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background text-foreground border-border hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
206
src/components/admin/reports-dashboard.tsx
Normal file
206
src/components/admin/reports-dashboard.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'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
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
} from '@/components/ui/accordion';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
/** Default permissions structure matching RolePermissions type */
|
||||
/** Default permissions structure matching RolePermissions type in
|
||||
* src/lib/db/schema/users.ts. Keep this in sync when actions are added. */
|
||||
const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||||
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
||||
interests: {
|
||||
@@ -33,6 +34,7 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||||
documents: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
send_for_signing: false,
|
||||
upload_signed: false,
|
||||
delete: false,
|
||||
@@ -54,7 +56,7 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||||
record_payment: false,
|
||||
export: false,
|
||||
},
|
||||
files: { view: false, upload: false, delete: false, manage_folders: false },
|
||||
files: { view: false, upload: false, edit: false, delete: false, manage_folders: false },
|
||||
email: { view: false, send: false, configure_account: false },
|
||||
reminders: {
|
||||
view_own: false,
|
||||
@@ -67,6 +69,10 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||||
calendar: { connect: false, view_events: false },
|
||||
reports: { view_dashboard: false, view_analytics: false, export: false },
|
||||
document_templates: { view: false, generate: false, manage: false },
|
||||
yachts: { view: false, create: false, edit: false, delete: false, transfer: false },
|
||||
companies: { view: false, create: false, edit: false, delete: false },
|
||||
memberships: { view: false, manage: false },
|
||||
reservations: { view: false, create: false, activate: false, cancel: false },
|
||||
admin: {
|
||||
manage_users: false,
|
||||
view_audit_log: false,
|
||||
@@ -101,7 +107,13 @@ const GROUP_LABELS: Record<string, string> = {
|
||||
calendar: 'Calendar',
|
||||
reports: 'Reports',
|
||||
document_templates: 'Document Templates',
|
||||
yachts: 'Yachts',
|
||||
companies: 'Companies',
|
||||
memberships: 'Company Memberships',
|
||||
reservations: 'Reservations',
|
||||
admin: 'Administration',
|
||||
residential_clients: 'Residential Clients',
|
||||
residential_interests: 'Residential Interests',
|
||||
};
|
||||
|
||||
function formatAction(action: string): string {
|
||||
|
||||
200
src/components/admin/sends-log.tsx
Normal file
200
src/components/admin/sends-log.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { formatDistanceToNow, format } from 'date-fns';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface SendRow {
|
||||
id: string;
|
||||
portId: string;
|
||||
recipientEmail: string;
|
||||
documentKind: 'berth_pdf' | 'brochure' | string;
|
||||
fromAddress: string;
|
||||
bodyMarkdown: string | null;
|
||||
sentAt: string;
|
||||
failedAt: string | null;
|
||||
errorReason: string | null;
|
||||
fallbackToLinkReason: string | null;
|
||||
messageId: string | null;
|
||||
berthId: string | null;
|
||||
brochureId: string | null;
|
||||
clientId: string | null;
|
||||
interestId: string | null;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
data: SendRow[];
|
||||
pagination: { nextCursor: { sentAt: string; id: string } | null };
|
||||
counts: { sent: number; failed: number; all: number };
|
||||
}
|
||||
|
||||
export function SendsLog() {
|
||||
const [status, setStatus] = useState<'all' | 'sent' | 'failed'>('all');
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['document-sends', status],
|
||||
queryFn: () => apiFetch<ListResponse>(`/api/v1/admin/document-sends?status=${status}`),
|
||||
});
|
||||
|
||||
const counts = data?.counts ?? { sent: 0, failed: 0, all: 0 };
|
||||
const rows = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Send log"
|
||||
description="Every brochure and per-berth PDF sent from the CRM, with delivery failures surfaced for retry."
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 mt-6 flex-wrap">
|
||||
<FilterChip
|
||||
label={`All (${counts.all})`}
|
||||
active={status === 'all'}
|
||||
onClick={() => setStatus('all')}
|
||||
/>
|
||||
<FilterChip
|
||||
label={`Sent (${counts.sent})`}
|
||||
active={status === 'sent'}
|
||||
onClick={() => setStatus('sent')}
|
||||
/>
|
||||
<FilterChip
|
||||
label={`Failed (${counts.failed})`}
|
||||
active={status === 'failed'}
|
||||
onClick={() => setStatus('failed')}
|
||||
accent={counts.failed > 0 ? 'danger' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground py-6">Loading…</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-red-600 py-6">
|
||||
Failed to load sends: {error instanceof Error ? error.message : 'unknown error'}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
No sends recorded for this filter yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const sent = new Date(row.sentAt);
|
||||
const ago = formatDistanceToNow(sent, { addSuffix: true });
|
||||
const isOpen = expanded === row.id;
|
||||
const failed = !!row.failedAt;
|
||||
return (
|
||||
<Card key={row.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
className={
|
||||
failed ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
|
||||
}
|
||||
>
|
||||
{failed ? 'Failed' : 'Sent'}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{row.documentKind === 'berth_pdf'
|
||||
? 'Berth PDF'
|
||||
: row.documentKind === 'brochure'
|
||||
? 'Brochure'
|
||||
: row.documentKind}
|
||||
</Badge>
|
||||
{row.fallbackToLinkReason ? (
|
||||
<Badge className="bg-amber-100 text-amber-800">
|
||||
Switched to download link
|
||||
</Badge>
|
||||
) : null}
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
title={sent.toISOString()}
|
||||
>
|
||||
{ago} · {format(sent, 'PP p')}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="mt-2 text-base font-medium">
|
||||
{row.recipientEmail}
|
||||
</CardTitle>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
From {row.fromAddress}
|
||||
{row.messageId ? (
|
||||
<span className="text-xs ml-2 font-mono">{row.messageId}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{failed && row.errorReason ? (
|
||||
<div className="mt-2 text-sm text-red-700 bg-red-50 rounded-md p-2">
|
||||
{row.errorReason}
|
||||
</div>
|
||||
) : null}
|
||||
{row.fallbackToLinkReason ? (
|
||||
<div className="mt-2 text-sm text-amber-700 bg-amber-50 rounded-md p-2">
|
||||
Attachment dropped → sent as link. Reason: {row.fallbackToLinkReason}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{row.bodyMarkdown ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setExpanded(isOpen ? null : row.id)}
|
||||
>
|
||||
{isOpen ? 'Hide body' : 'View body'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isOpen && row.bodyMarkdown ? (
|
||||
<CardContent>
|
||||
<pre className="bg-muted/40 rounded-md p-3 text-xs overflow-auto max-h-96 whitespace-pre-wrap">
|
||||
{row.bodyMarkdown}
|
||||
</pre>
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
accent?: 'danger';
|
||||
}) {
|
||||
const base = active
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background text-foreground border-border hover:bg-muted';
|
||||
const dangerActive =
|
||||
accent === 'danger' && active ? 'bg-red-600 text-white border-red-600' : null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`px-3 py-1.5 rounded-full text-sm border transition ${dangerActive ?? base}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +118,77 @@ const KNOWN_SETTINGS: Array<{
|
||||
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
|
||||
},
|
||||
},
|
||||
// ─── Berth recommender (src/lib/services/berth-recommender.service.ts) ──────
|
||||
{
|
||||
key: 'recommender_max_oversize_pct',
|
||||
label: 'Recommender — max oversize %',
|
||||
description:
|
||||
'Cap on how much larger a berth can be than the desired length/width/draft before it stops being suggested. Default 30.',
|
||||
type: 'number',
|
||||
defaultValue: 30,
|
||||
},
|
||||
{
|
||||
key: 'recommender_top_n_default',
|
||||
label: 'Recommender — default result count',
|
||||
description: 'Default number of berth recommendations returned per request. Default 8.',
|
||||
type: 'number',
|
||||
defaultValue: 8,
|
||||
},
|
||||
{
|
||||
key: 'fallthrough_policy',
|
||||
label: 'Recommender — fall-through policy',
|
||||
description:
|
||||
'How berths re-enter the recommender after a lost deal. One of: immediate_with_heat, cooldown, never_auto_recommend.',
|
||||
type: 'string',
|
||||
defaultValue: 'immediate_with_heat',
|
||||
},
|
||||
{
|
||||
key: 'fallthrough_cooldown_days',
|
||||
label: 'Recommender — fall-through cooldown (days)',
|
||||
description:
|
||||
'Days a berth stays out of the recommender after a lost deal when the policy is `cooldown`. Default 30.',
|
||||
type: 'number',
|
||||
defaultValue: 30,
|
||||
},
|
||||
{
|
||||
key: 'heat_weight_recency',
|
||||
label: 'Heat weight — recency',
|
||||
description: 'Weight given to how recently the prior interest fell through. Default 30.',
|
||||
type: 'number',
|
||||
defaultValue: 30,
|
||||
},
|
||||
{
|
||||
key: 'heat_weight_furthest_stage',
|
||||
label: 'Heat weight — furthest stage',
|
||||
description:
|
||||
'Weight given to how close the prior interest got to closing before falling through. Default 40.',
|
||||
type: 'number',
|
||||
defaultValue: 40,
|
||||
},
|
||||
{
|
||||
key: 'heat_weight_interest_count',
|
||||
label: 'Heat weight — historical interest count',
|
||||
description:
|
||||
'Weight given to how often this berth has attracted interest historically. Default 15.',
|
||||
type: 'number',
|
||||
defaultValue: 15,
|
||||
},
|
||||
{
|
||||
key: 'heat_weight_eoi_count',
|
||||
label: 'Heat weight — historical EOI count',
|
||||
description:
|
||||
'Weight given to how often interest in this berth has reached EOI signing. Default 15.',
|
||||
type: 'number',
|
||||
defaultValue: 15,
|
||||
},
|
||||
{
|
||||
key: 'tier_ladder_hide_late_stage',
|
||||
label: 'Recommender — hide late-stage tier',
|
||||
description:
|
||||
'Hide berths whose only active interests are late-stage (close to closing) from recommendations.',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsManager() {
|
||||
|
||||
Reference in New Issue
Block a user