peregrine/web/src/components/InterviewCard.vue

430 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]
}>()
// 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' })
} 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 class="research-badge research-badge--done">🔬 Research ready</div>
</div>
<footer class="card-footer">
<button class="card-action" @click.stop="emit('move', job.id)">Move to </button>
<button class="card-action" @click.stop="emit('prep', job.id)">Prep </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;
}
.research-badge {
display: inline-flex;
align-items: center;
gap: 3px;
border-radius: 99px;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 700;
align-self: flex-start;
margin-top: 2px;
}
.research-badge--done {
background: color-mix(in srgb, var(--status-phone) 12%, var(--color-surface-raised));
color: var(--status-phone);
border: 1px solid color-mix(in srgb, var(--status-phone) 30%, var(--color-surface-raised));
}
.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>