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
/ * * P h a s e 6 — p o p u l a t e d b y t h e I M A P b o u n c e p o l l e r w h e n a d e l i v e r y
* failure for this send was matched in the configured mailbox . * /
bounceStatus : 'hard' | 'soft' | 'ooo' | null ;
bounceReason : string | null ;
bounceDetectedAt : string | null ;
2026-05-21 23:39:19 +02:00
/ * * P h a s e 4 b e m a i l - o p e n t r a c k i n g . ` o p e n C o u n t ` i s d e n o r m a l i s e d o n e v e r y
* 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 }
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 >
) ;
}