Files
pn-new-crm/src/components/admin/sends-log.tsx

266 lines
10 KiB
TypeScript
Raw Normal View History

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
'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;
feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
/** Phase 6 populated by the IMAP bounce poller when a delivery
* failure for this send was matched in the configured mailbox. */
bounceStatus: 'hard' | 'soft' | 'ooo' | null;
bounceReason: string | null;
bounceDetectedAt: string | null;
feat(uat-batch): Group O — Umami in-repo polish O48, O51-O54 from the 2026-05-21 plan. Phase 4a / 3 / 5 marketing-site work explicitly deferred — they live in the marketing repo + are blocked on instrumentation that isn't this codebase's to ship. Shipped: O48 Tracked-link composer button. New POST /api/v1/tracked-links mints a redirect-link the rep can drop into an outgoing email. Body { targetUrl, sendId? }; returns { id, slug, targetUrl, url }. Gated on `email.send` (same as the server-side check on existing send routes). `sendId` lets the click-tracker attribute back to a specific document_sends row. <TrackedLinkComposerButton> renders a small inline button (or a sized default variant) that opens a dialog: rep pastes the destination URL → Create → gets the public /q/<slug> URL with a Copy + an "Insert into message" action that calls back to the parent compose surface. Wired into <SendDocumentDialog>'s Message body label row so reps can mint + insert without leaving the dialog. O51 Quiet-range nudge. WebsiteAnalyticsShell surfaces a small amber banner when the active range returned <5 visitors so the rep doesn't think the integration is broken on a fresh port or off-season range. Threshold keeps the banner off legitimate traffic. O52 Apple Mail privacy disclaimer. The sends-log "Not opened" badge carries an inline tooltip explaining that Apple Mail's privacy protection routes opens through Apple's proxy and can suppress this signal even when the recipient read the email. O53 Open-rate column on the document_sends list. SendRow type extended with `trackOpens` / `openCount` / `firstOpenedAt`; the sends-log card chrome renders an "Opened × N" badge with the first-open timestamp in the title, or "Not opened" when tracking is on but no opens yet, or no badge at all when tracking was disabled for that send. O54 Click-to-filter world map. VisitorWorldMap already supported `onCountryClick`; wired it through to copy the `/<portSlug>/clients?nationality=<ISO>` deep-link to the clipboard with a toast on click. Inline filtering of the analytics view itself stays parked alongside Phase 5 — the useUmami* hooks don't yet accept a country filter. Deferred (not in this repo or blocked): O47 Phase 4a marketing-site instrumentation — marketing repo work. O49 Phase 3 Events tab — blocked on 4a. O50 Phase 5 Funnels + Journeys — blocked on 4a. Verified: tsc clean, vitest 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:39:19 +02:00
/** Phase 4b email-open tracking. `openCount` is denormalised on every
* pixel hit; `firstOpenedAt` stamps the first time the recipient
* loaded the email. Both stay 0 / null when `trackOpens` is off. */
trackOpens: boolean;
openCount: number;
firstOpenedAt: string | null;
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
}
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}
feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
{row.bounceStatus ? (
<Badge
className={
row.bounceStatus === 'ooo'
? 'bg-slate-100 text-slate-800'
: 'bg-rose-100 text-rose-800'
}
>
{row.bounceStatus === 'hard'
? 'Hard bounce'
: row.bounceStatus === 'soft'
? 'Soft bounce'
: 'Out of office'}
</Badge>
) : null}
feat(uat-batch): Group O — Umami in-repo polish O48, O51-O54 from the 2026-05-21 plan. Phase 4a / 3 / 5 marketing-site work explicitly deferred — they live in the marketing repo + are blocked on instrumentation that isn't this codebase's to ship. Shipped: O48 Tracked-link composer button. New POST /api/v1/tracked-links mints a redirect-link the rep can drop into an outgoing email. Body { targetUrl, sendId? }; returns { id, slug, targetUrl, url }. Gated on `email.send` (same as the server-side check on existing send routes). `sendId` lets the click-tracker attribute back to a specific document_sends row. <TrackedLinkComposerButton> renders a small inline button (or a sized default variant) that opens a dialog: rep pastes the destination URL → Create → gets the public /q/<slug> URL with a Copy + an "Insert into message" action that calls back to the parent compose surface. Wired into <SendDocumentDialog>'s Message body label row so reps can mint + insert without leaving the dialog. O51 Quiet-range nudge. WebsiteAnalyticsShell surfaces a small amber banner when the active range returned <5 visitors so the rep doesn't think the integration is broken on a fresh port or off-season range. Threshold keeps the banner off legitimate traffic. O52 Apple Mail privacy disclaimer. The sends-log "Not opened" badge carries an inline tooltip explaining that Apple Mail's privacy protection routes opens through Apple's proxy and can suppress this signal even when the recipient read the email. O53 Open-rate column on the document_sends list. SendRow type extended with `trackOpens` / `openCount` / `firstOpenedAt`; the sends-log card chrome renders an "Opened × N" badge with the first-open timestamp in the title, or "Not opened" when tracking is on but no opens yet, or no badge at all when tracking was disabled for that send. O54 Click-to-filter world map. VisitorWorldMap already supported `onCountryClick`; wired it through to copy the `/<portSlug>/clients?nationality=<ISO>` deep-link to the clipboard with a toast on click. Inline filtering of the analytics view itself stays parked alongside Phase 5 — the useUmami* hooks don't yet accept a country filter. Deferred (not in this repo or blocked): O47 Phase 4a marketing-site instrumentation — marketing repo work. O49 Phase 3 Events tab — blocked on 4a. O50 Phase 5 Funnels + Journeys — blocked on 4a. Verified: tsc clean, vitest 1454/1454. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:39:19 +02:00
{row.trackOpens ? (
row.openCount > 0 ? (
<Badge
className="bg-emerald-100 text-emerald-800"
title={
row.firstOpenedAt
? `First opened ${format(new Date(row.firstOpenedAt), 'PP p')} · ${row.openCount} open${row.openCount === 1 ? '' : 's'}`
: `${row.openCount} open${row.openCount === 1 ? '' : 's'}`
}
>
Opened {row.openCount > 1 ? `× ${row.openCount}` : ''}
</Badge>
) : (
<Badge
variant="secondary"
className="text-xs"
title="Tracking pixel embedded but no opens recorded yet. Apple Mail's privacy protection routes opens through Apple's proxy, which can suppress this signal even when the recipient read the email."
>
Not opened
</Badge>
)
) : null}
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
<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}
feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md Three of the master plan's "suggested execution order" items shipped this session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the remaining session time. - Phase 4 polish: yachtId field on <ReminderForm> via the existing YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter by yachtId, getReminder joins the yacht relation. - Phase 2 risk-signal data wiring: getInterestById derives the 3 dates (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther) from document_events / berth_reservations / cross-interest interest_berths in parallel — chosen over new schema columns to keep the master plan's "no new tables" promise. Threaded through to DealPulseChip. - Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the configured IMAP mailbox (IMAP_* env), matches NDRs to recent document_sends rows via recipient + 7-day window, idempotent via bounceDetectedAt, fires email_bounced notifications on hard/soft (skips OOO). State persisted to system_settings.bounce_poller_state. Wired into maintenance queue at */15 * * * *. Admin /admin/sends page surfaces the bounce badge + reason inline. - CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy Documenso webhook / v1-v2 routing / Document folders sections rewritten as scannable bullets. Added a new "Working in this repo — skills, MCPs, agents" section promoting brainstorming/TDD/debugging/frontend-design skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev agents. Documented Phase 2 derivation choice in the data-model section. Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:38:37 +02:00
{row.bounceStatus && row.bounceReason ? (
<div
className={`mt-2 text-sm rounded-md p-2 ${
row.bounceStatus === 'ooo'
? 'text-slate-700 bg-slate-50'
: 'text-rose-700 bg-rose-50'
}`}
>
Bounced
{row.bounceDetectedAt
? ` ${formatDistanceToNow(new Date(row.bounceDetectedAt), {
addSuffix: true,
})}`
: ''}
: {row.bounceReason}
</div>
) : null}
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
</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>
);
}