Add outreach rejection reasons tab

This commit is contained in:
jjbrunton committed 2026-08-14 15:18:46 +01:00
1 parent 7af7b211fa
commit 0c21202253
7 files changed
+114 -26

No files matched your search

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
@@ -11,6 +11,8 @@ import { DeleteCandidateDialog } from './delete-candidate-dialog.js';
import { EditMessageDialog } from './edit-message-dialog.js';
import './outreach.css';
type OutreachTab = 'active' | 'rejections';
const STAGES: ReadonlyArray<{
status: OutreachCandidateStatus;
label: string;
@@ -173,6 +175,19 @@ export function OutreachPage() {
const [editingCandidate, setEditingCandidate] = useState<OutreachCandidate | null>(null);
const [deletingCandidate, setDeletingCandidate] = useState<OutreachCandidate | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<OutreachTab>('active');
const activeTabRef = useRef<HTMLButtonElement>(null);
const rejectionsTabRef = useRef<HTMLButtonElement>(null);
const selectTab = (tab: OutreachTab, focus = false) => {
setSelectedTab(tab);
if (focus) (tab === 'active' ? activeTabRef : rejectionsTabRef).current?.focus();
};
const handleTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'Home' && event.key !== 'End') return;
event.preventDefault();
selectTab(event.key === 'ArrowLeft' || event.key === 'Home' ? 'active' : 'rejections', true);
};
// Single client-side clock for every displayed offset: retry countdowns tick
// every second while visible, everything else every 10 seconds.
@@ -257,30 +272,79 @@ export function OutreachPage() {
<p className="runway-caption">Retrying posts loop back to Ready when their cooldown expires. Sent and not-sent totals, including manually cleared candidates, cover the lifetime of this queue.</p>
</section>
<section aria-labelledby="active-candidates-heading" className="candidate-section">
<div className="panel-heading">
<div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div>
<span className="candidate-updated">
{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}
Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s
</span>
</div>
{data.candidates.length === 0 ? (
<div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div>
) : (
<ol aria-label="Active outreach candidates" className="outreach-candidates">
{data.candidates.map((candidate) => (
<CandidateCard
candidate={candidate}
key={candidate.postId}
now={now}
onDelete={(selected) => { clearMutation.reset(); deleteMutation.reset(); setActionNotice(null); setDeletingCandidate(selected); }}
onEdit={(selected) => { clearMutation.reset(); editMutation.reset(); setActionNotice(null); setEditingCandidate(selected); }}
/>
))}
</ol>
)}
</section>
<div aria-label="Outreach funnel views" className="outreach-tabs" role="tablist">
<button
aria-controls="outreach-active-panel"
aria-selected={selectedTab === 'active'}
className="outreach-tab"
id="outreach-active-tab"
onClick={() => selectTab('active')}
onKeyDown={handleTabKeyDown}
ref={activeTabRef}
role="tab"
tabIndex={selectedTab === 'active' ? 0 : -1}
type="button"
>
Active queue <span>{data.activeCount}</span>
</button>
<button
aria-controls="outreach-rejections-panel"
aria-selected={selectedTab === 'rejections'}
className="outreach-tab"
id="outreach-rejections-tab"
onClick={() => selectTab('rejections')}
onKeyDown={handleTabKeyDown}
ref={rejectionsTabRef}
role="tab"
tabIndex={selectedTab === 'rejections' ? 0 : -1}
type="button"
>
Rejections <span>{data.outcomes.rejected}</span>
</button>
</div>
{selectedTab === 'active' ? (
<section aria-labelledby="outreach-active-tab" className="candidate-section" id="outreach-active-panel" role="tabpanel">
<div className="panel-heading">
<div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div>
<span className="candidate-updated">
{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}
Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s
</span>
</div>
{data.candidates.length === 0 ? (
<div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div>
) : (
<ol aria-label="Active outreach candidates" className="outreach-candidates">
{data.candidates.map((candidate) => (
<CandidateCard
candidate={candidate}
key={candidate.postId}
now={now}
onDelete={(selected) => { clearMutation.reset(); deleteMutation.reset(); setActionNotice(null); setDeletingCandidate(selected); }}
onEdit={(selected) => { clearMutation.reset(); editMutation.reset(); setActionNotice(null); setEditingCandidate(selected); }}
/>
))}
</ol>
)}
</section>
) : (
<section aria-labelledby="outreach-rejections-tab" className="rejection-section" id="outreach-rejections-panel" role="tabpanel">
<div className="panel-heading">
<div><span className="eyebrow">Terminal screening outcomes</span><h2>Rejections</h2></div>
<span className="candidate-updated">Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s</span>
</div>
{data.rejectionReasons.length === 0 ? (
<div className="empty-state panel"><strong>No rejection reasons recorded.</strong><span>Rejected candidates will be grouped here as the funnel screens them.</span></div>
) : (
<ol aria-label="Outreach rejection reasons" className="rejection-reasons">
{data.rejectionReasons.map(({ reason, count }) => (
<li key={reason}><span>{reason}</span><strong>{count}</strong></li>
))}
</ol>
)}
</section>
)}
{isConfirmingClear ? (
<ClearFunnelDialog
clearableCount={clearableCount}
@@ -20,6 +20,16 @@
.stage-retryable .stage-marker { background: var(--warning); }
.runway-caption { position: relative; margin: .9rem 0 0; color: var(--muted); font-size: .78rem; }
.candidate-section { margin-top: .5rem; }
.outreach-tabs { display: flex; width: fit-content; max-width: 100%; overflow-x: auto; padding: .25rem; background: #101a16; border: 1px solid #355044; border-radius: .7rem; }
.outreach-tab { display: inline-flex; align-items: center; gap: .45rem; min-height: 2.3rem; padding: .45rem .7rem; color: var(--muted); background: transparent; border: 0; border-radius: .45rem; font: inherit; font-size: .8rem; font-weight: 750; white-space: nowrap; cursor: pointer; }
.outreach-tab[aria-selected="true"] { color: #eef6f1; background: #20362b; box-shadow: inset 0 0 0 1px #426655; }
.outreach-tab span { min-width: 1.45rem; padding: .08rem .35rem; color: #dce8e2; background: rgba(9, 17, 14, .7); border-radius: 999px; font-size: .7rem; text-align: center; }
.outreach-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.rejection-section { display: grid; gap: .75rem; }
.rejection-reasons { display: grid; gap: .55rem; padding: 0; margin: 0; list-style: none; }
.rejection-reasons li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 1rem; padding: .8rem .9rem; color: #e7d9b1; background: #211b12; border: 1px solid #4c3b1d; border-left: 3px solid var(--warning); border-radius: .65rem; }
.rejection-reasons span { overflow-wrap: anywhere; }
.rejection-reasons strong { min-width: 2rem; color: var(--warning-text); font-size: 1.1rem; text-align: right; }
.candidate-updated { color: var(--muted); font-size: .8rem; }
.outreach-candidates { display: grid; gap: .7rem; padding: 0; margin: 0; list-style: none; }
.outreach-candidate { display: grid; grid-template-columns: .32rem minmax(0, 1fr); overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .85rem; }