Add outreach rejection reasons tab
This commit is contained in:
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; }
|
||||
|
||||
Reference in new issue
Block a user