peregrine/web/src/components/InterviewCard.vue
pyr0ball b79d13b4f2
Some checks failed
CI / test (push) Failing after 30s
feat(vue): parity gaps #50, #54, #61 — sort/filter, research modal, draft CL button
#50 Job Review list view — sort + filter controls:
- Sort by best match / newest first / company A-Z (client-side computed)
- Remote-only checkbox filter
- Job count indicator; filters reset on tab switch
- Remote badge on list items

#61 Cover letter generation from approved tab:
- ' Draft' button on each approved-list item → /apply/:id
- No extra API call; ApplyWorkspace handles generation from there

#54 Company research modal (all API endpoints already existed):
- CompanyResearchModal.vue: 3-state machine (empty→generating→ready)
  polling /research/task every 3s, displays all 7 research sections
  (company, leadership, talking points, tech, funding, red flags,
  accessibility), copy-to-clipboard for talking points, ↺ Refresh
- InterviewCard: new 'research' emit + '🔍 Research' button for
  phone_screen/interviewing/offer stages
- InterviewsView: wires modal with researchJobId/Title/AutoGen state;
  auto-opens modal with autoGenerate=true when a job is moved to
  phone_screen (mirrors Streamlit behaviour)
2026-04-02 19:26:13 -07:00

428 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed } from 'vue'
import type { PipelineJob } from '../stores/interviews'
import type { StageSignal, PipelineStage } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi'
const props = defineProps<{
job: PipelineJob
focused?: boolean
}>()
const emit = defineEmits<{
move: [jobId: number, preSelectedStage?: PipelineStage]
prep: [jobId: number]
survey: [jobId: number]
research: [jobId: number]
}>()
// Signal state
const sigExpanded = ref(false)
interface SignalMeta {
label: string
stage: PipelineStage
color: 'amber' | 'green' | 'red'
}
const SIGNAL_META: Record<StageSignal['stage_signal'], SignalMeta> = {
interview_scheduled: { label: 'Move to Phone Screen', stage: 'phone_screen', color: 'amber' },
positive_response: { label: 'Move to Phone Screen', stage: 'phone_screen', color: 'amber' },
offer_received: { label: 'Move to Offer', stage: 'offer', color: 'green' },
survey_received: { label: 'Move to Survey', stage: 'survey', color: 'amber' },
rejected: { label: 'Mark Rejected', stage: 'interview_rejected', color: 'red' },
}
const COLOR_BG: Record<'amber' | 'green' | 'red', string> = {
amber: 'rgba(245,158,11,0.08)',
green: 'rgba(39,174,96,0.08)',
red: 'rgba(192,57,43,0.08)',
}
const COLOR_BORDER: Record<'amber' | 'green' | 'red', string> = {
amber: 'rgba(245,158,11,0.4)',
green: 'rgba(39,174,96,0.4)',
red: 'rgba(192,57,43,0.4)',
}
function visibleSignals(): StageSignal[] {
const sigs = props.job.stage_signals ?? []
return sigExpanded.value ? sigs : sigs.slice(0, 1)
}
async function dismissSignal(sig: StageSignal) {
// Optimistic removal
const arr = props.job.stage_signals
const idx = arr.findIndex(s => s.id === sig.id)
if (idx !== -1) arr.splice(idx, 1)
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
}
const expandedSignalIds = ref(new Set<number>())
function toggleBodyExpand(sigId: number) {
const next = new Set(expandedSignalIds.value)
if (next.has(sigId)) next.delete(sigId)
else next.add(sigId)
expandedSignalIds.value = next
}
// Re-classify chips — neutral/unrelated/digest trigger two-call dismiss path
const RECLASSIFY_CHIPS = [
{ label: '🟡 Interview', value: 'interview_scheduled' as const },
{ label: '✅ Positive', value: 'positive_response' as const },
{ label: '🟢 Offer', value: 'offer_received' as const },
{ label: '📋 Survey', value: 'survey_received' as const },
{ label: '✖ Rejected', value: 'rejected' as const },
{ label: '🚫 Unrelated', value: 'unrelated' },
{ label: '📰 Digest', value: 'digest' },
{ label: '— Neutral', value: 'neutral' },
] as const
const DISMISS_LABELS = new Set(['neutral', 'unrelated', 'digest'] as const)
async function reclassifySignal(sig: StageSignal, newLabel: StageSignal['stage_signal'] | 'neutral' | 'unrelated' | 'digest') {
if (DISMISS_LABELS.has(newLabel)) {
// Optimistic removal
const arr = props.job.stage_signals
const idx = arr.findIndex(s => s.id === sig.id)
if (idx !== -1) arr.splice(idx, 1)
// Two-call path: persist label (Avocet training hook) then dismiss
await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: newLabel }),
})
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
// Digest-only: add to browsable queue (fire-and-forget; sig.id === job_contacts.id)
if (newLabel === 'digest') {
void useApiFetch('/api/digest-queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_contact_id: sig.id }),
})
}
} else {
const prev = sig.stage_signal
sig.stage_signal = newLabel
const { error } = await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: newLabel }),
})
if (error) sig.stage_signal = prev
}
}
const scoreClass = computed(() => {
const s = (props.job.match_score ?? 0) * 100
if (s >= 85) return 'score--high'
if (s >= 65) return 'score--mid'
return 'score--low'
})
const scoreLabel = computed(() =>
props.job.match_score != null
? `${Math.round(props.job.match_score * 100)}%`
: '—'
)
const interviewDateLabel = computed(() => {
if (!props.job.interview_date) return null
const d = new Date(props.job.interview_date)
const now = new Date()
const diffDays = Math.round((d.getTime() - now.getTime()) / 86400000)
const timeStr = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
if (diffDays === 0) return `Today ${timeStr}`
if (diffDays === 1) return `Tomorrow ${timeStr}`
if (diffDays === -1) return `Yesterday ${timeStr}`
if (diffDays > 1 && diffDays < 7) return `${d.toLocaleDateString([], { weekday: 'short' })} ${timeStr}`
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
})
const dateChipIcon = computed(() => {
if (!props.job.interview_date) return ''
const map: Record<string, string> = { phone_screen: '📞', interviewing: '🎯', offer: '📜' }
return map[props.job.status] ?? '📅'
})
const columnColor = computed(() => {
const map: Record<string, string> = {
phone_screen: 'var(--status-phone)',
interviewing: 'var(--color-info)',
offer: 'var(--status-offer)',
hired: 'var(--color-success)',
}
return map[props.job.status] ?? 'var(--color-border)'
})
</script>
<template>
<article
class="interview-card"
:class="{ 'interview-card--focused': focused }"
:style="{ '--card-accent': columnColor }"
tabindex="0"
:aria-label="`${job.title} at ${job.company}`"
@keydown.enter="emit('prep', job.id)"
@keydown.m.exact="emit('move', job.id)"
>
<div class="card-body">
<div class="card-title">{{ job.title }}</div>
<div class="card-company">
{{ job.company }}
<span v-if="job.salary" class="card-salary">· {{ job.salary }}</span>
</div>
<div class="card-badges">
<span class="score-badge" :class="scoreClass">{{ scoreLabel }}</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
</div>
<div v-if="interviewDateLabel" class="date-chip">
{{ dateChipIcon }} {{ interviewDateLabel }}
</div>
</div>
<footer class="card-footer">
<button class="card-action" @click.stop="emit('move', job.id)">Move to </button>
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('research', job.id)">🔍 Research</button>
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('prep', job.id)">Prep </button>
<button
v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"
class="card-action"
@click.stop="emit('survey', job.id)"
>Survey </button>
</footer>
<!-- Signal banners -->
<template v-if="job.stage_signals?.length">
<div
v-for="sig in visibleSignals()"
:key="sig.id"
class="signal-banner"
:style="{
background: COLOR_BG[SIGNAL_META[sig.stage_signal].color],
borderTopColor: COLOR_BORDER[SIGNAL_META[sig.stage_signal].color],
}"
>
<div class="signal-header">
<span class="signal-label">
📧 <strong>{{ SIGNAL_META[sig.stage_signal].label.replace('Move to ', '') }}</strong>
</span>
<span class="signal-subject">{{ sig.subject.slice(0, 60) }}{{ sig.subject.length > 60 ? '…' : '' }}</span>
<div class="signal-header-actions">
<button class="btn-signal-read" @click.stop="toggleBodyExpand(sig.id)">
{{ expandedSignalIds.has(sig.id) ? '▾ Hide' : '▸ Read' }}
</button>
<button
class="btn-signal-move"
@click.stop="emit('move', props.job.id, SIGNAL_META[sig.stage_signal].stage)"
:aria-label="`${SIGNAL_META[sig.stage_signal].label} for ${props.job.title}`"
> Move</button>
<button
class="btn-signal-dismiss"
@click.stop="dismissSignal(sig)"
aria-label="Dismiss signal"
></button>
</div>
</div>
<!-- Expanded body + reclassify chips -->
<div v-if="expandedSignalIds.has(sig.id)" class="signal-body-expanded">
<div v-if="sig.from_addr" class="signal-from">From: {{ sig.from_addr }}</div>
<div v-if="sig.body" class="signal-body-text">{{ sig.body }}</div>
<div v-else class="signal-body-empty">No email body available.</div>
<div class="signal-reclassify">
<span class="signal-reclassify-label">Re-classify:</span>
<button
v-for="chip in RECLASSIFY_CHIPS"
:key="chip.value"
class="btn-chip"
:class="{ 'btn-chip-active': sig.stage_signal === chip.value }"
@click.stop="reclassifySignal(sig, chip.value)"
>{{ chip.label }}</button>
</div>
</div>
</div>
<button
v-if="(job.stage_signals?.length ?? 0) > 1"
class="btn-sig-expand"
@click.stop="sigExpanded = !sigExpanded"
>{{ sigExpanded ? ' less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
</template>
</article>
</template>
<style scoped>
.interview-card {
background: var(--color-surface-raised);
border-radius: 10px;
border-left: 4px solid var(--card-accent, var(--color-border));
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
cursor: pointer;
outline: none;
transition: box-shadow 150ms;
}
.interview-card--focused,
.interview-card:focus-visible {
box-shadow: 0 0 0 3px var(--card-accent, var(--color-primary));
}
.card-body {
padding: 10px 12px 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.card-title {
font-weight: 700;
font-size: 0.875rem;
color: var(--color-text);
line-height: 1.2;
}
.card-company {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.card-salary {
color: var(--color-text-muted);
}
.card-badges {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 2px;
}
.score-badge {
border-radius: 99px;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 700;
}
.score--high {
background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface-raised));
color: var(--color-success);
}
.score--mid {
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface-raised));
color: var(--color-warning);
}
.score--low {
background: color-mix(in srgb, var(--color-error) 18%, var(--color-surface-raised));
color: var(--color-error);
}
.remote-badge {
border-radius: 99px;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 700;
background: color-mix(in srgb, var(--color-info) 14%, var(--color-surface-raised));
color: var(--color-info);
}
.date-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: color-mix(in srgb, var(--color-info) 12%, var(--color-surface-raised));
color: var(--color-info);
border-radius: 6px;
padding: 3px 8px;
font-size: 0.7rem;
font-weight: 700;
margin-top: 2px;
align-self: flex-start;
}
.card-footer {
border-top: 1px solid var(--color-border-light);
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
background: color-mix(in srgb, var(--color-surface) 60%, transparent);
}
.card-action {
background: none;
border: none;
cursor: pointer;
font-size: 0.7rem;
font-weight: 700;
color: var(--color-info);
padding: 2px 4px;
border-radius: 4px;
}
.card-action:hover {
background: var(--color-surface);
}
.signal-banner {
border-top: 1px solid transparent; /* color set inline */
padding: 8px 12px;
display: flex; flex-direction: column; gap: 4px;
}
.signal-label { font-size: 0.82em; }
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
.btn-signal-move {
background: var(--color-primary); color: #fff;
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
}
.btn-signal-dismiss {
background: none; border: none; color: var(--color-text-muted); font-size: 0.85em; cursor: pointer;
padding: 2px 4px;
}
.btn-signal-read {
background: none; border: none; color: var(--color-text-muted); font-size: 0.82em;
cursor: pointer; padding: 2px 6px; white-space: nowrap;
}
.signal-header {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.signal-header-actions {
margin-left: auto; display: flex; gap: 6px; align-items: center;
}
.signal-body-expanded {
margin-top: 8px; font-size: 0.8em; border-top: 1px dashed var(--color-border);
padding-top: 8px;
}
.signal-from {
color: var(--color-text-muted); margin-bottom: 4px;
}
.signal-body-text {
white-space: pre-wrap; color: var(--color-text); line-height: 1.5;
max-height: 200px; overflow-y: auto;
}
.signal-body-empty {
color: var(--color-text-muted); font-style: italic;
}
.signal-reclassify {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px;
}
.signal-reclassify-label {
font-size: 0.75em; color: var(--color-text-muted);
}
.btn-chip {
background: var(--color-surface); color: var(--color-text-muted);
border: 1px solid var(--color-border); border-radius: 4px;
padding: 2px 7px; font-size: 0.75em; cursor: pointer;
}
.btn-chip:hover {
background: var(--color-hover);
}
.btn-chip-active {
background: var(--color-primary-muted, #e8f0ff);
color: var(--color-primary); border-color: var(--color-primary);
font-weight: 600;
}
.btn-sig-expand {
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
padding: 4px 12px; text-align: left;
}
</style>