feat: shadow listing detector, hired feedback widget, contacts manager

Shadow listing detector (#95):
- Capture date_posted from JobSpy in discover.py + insert_job()
- Add date_posted migration to _MIGRATIONS
- _shadow_score() heuristic: 'shadow' (≥30 days stale), 'stale' (≥14 days)
- list_jobs() computes shadow_score per listing
- JobCard.vue: 'Ghost post' and 'Stale' badges with tooltip

Post-hire feedback widget (#91):
- Add hired_feedback migration to _MIGRATIONS
- POST /api/jobs/:id/hired-feedback endpoint
- InterviewCard.vue: optional widget on hired cards with factor
  checkboxes + freetext; dismissible; shows saved state
- PipelineJob interface extended with hired_feedback field

Contacts manager (#73):
- GET /api/contacts endpoint with job join, direction/search filters
- New ContactsView.vue: searchable table, inbound/outbound filter,
  signal chip column, job link
- Route /contacts added; Contacts nav link (UsersIcon) in AppNav

Also: add git to Dockerfile apt-get for circuitforge-core editable install
This commit is contained in:
pyr0ball 2026-04-15 08:34:12 -07:00
parent 6599bc6952
commit 0e4fce44c4
11 changed files with 622 additions and 21 deletions

View file

@ -6,7 +6,7 @@ WORKDIR /app
# System deps for companyScraper (beautifulsoup4, fake-useragent, lxml) and PDF gen # System deps for companyScraper (beautifulsoup4, fake-useragent, lxml) and PDF gen
# libsqlcipher-dev: required to build pysqlcipher3 (SQLCipher AES-256 encryption for cloud mode) # libsqlcipher-dev: required to build pysqlcipher3 (SQLCipher AES-256 encryption for cloud mode)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libffi-dev curl libsqlcipher-dev \ gcc libffi-dev curl libsqlcipher-dev git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .

View file

@ -16,7 +16,7 @@ import subprocess
import sys import sys
import threading import threading
from contextvars import ContextVar from contextvars import ContextVar
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional, List from typing import Optional, List
from urllib.parse import urlparse from urllib.parse import urlparse
@ -230,6 +230,28 @@ def _row_to_job(row) -> dict:
return d return d
def _shadow_score(date_posted: str | None, date_found: str | None) -> str | None:
"""Return 'shadow', 'stale', or None based on posting age when discovered.
A job posted >30 days before discovery is a shadow candidate (posted to
satisfy legal/HR requirements, likely already filled). 14-30 days is stale.
Returns None when dates are unavailable.
"""
if not date_posted or not date_found:
return None
try:
posted = datetime.fromisoformat(date_posted.replace("Z", "+00:00")).replace(tzinfo=timezone.utc)
found = datetime.fromisoformat(date_found.replace("Z", "+00:00")).replace(tzinfo=timezone.utc)
days = (found - posted).days
if days >= 30:
return "shadow"
if days >= 14:
return "stale"
except (ValueError, TypeError):
pass
return None
# ── GET /api/jobs ───────────────────────────────────────────────────────────── # ── GET /api/jobs ─────────────────────────────────────────────────────────────
@app.get("/api/jobs") @app.get("/api/jobs")
@ -237,7 +259,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""):
db = _get_db() db = _get_db()
rows = db.execute( rows = db.execute(
"SELECT id, title, company, url, source, location, is_remote, salary, " "SELECT id, title, company, url, source, location, is_remote, salary, "
"description, match_score, keyword_gaps, date_found, status, cover_letter " "description, match_score, keyword_gaps, date_found, date_posted, status, cover_letter "
"FROM jobs WHERE status = ? ORDER BY match_score DESC NULLS LAST LIMIT ?", "FROM jobs WHERE status = ? ORDER BY match_score DESC NULLS LAST LIMIT ?",
(status, limit), (status, limit),
).fetchall() ).fetchall()
@ -246,7 +268,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""):
for r in rows: for r in rows:
d = _row_to_job(r) d = _row_to_job(r)
d["has_cover_letter"] = bool(d.get("cover_letter")) d["has_cover_letter"] = bool(d.get("cover_letter"))
# Don't send full cover_letter text in the list view d["shadow_score"] = _shadow_score(d.get("date_posted"), d.get("date_found"))
d.pop("cover_letter", None) d.pop("cover_letter", None)
result.append(d) result.append(d)
return result return result
@ -1490,6 +1512,65 @@ def suggest_qa_answer(job_id: int, payload: QASuggestPayload):
raise HTTPException(500, f"LLM generation failed: {e}") raise HTTPException(500, f"LLM generation failed: {e}")
# ── POST /api/jobs/:id/hired-feedback ─────────────────────────────────────────
class HiredFeedbackPayload(BaseModel):
what_helped: str = ""
factors: list[str] = []
@app.post("/api/jobs/{job_id}/hired-feedback")
def save_hired_feedback(job_id: int, payload: HiredFeedbackPayload):
db = _get_db()
row = db.execute("SELECT status FROM jobs WHERE id = ?", (job_id,)).fetchone()
if not row:
raise HTTPException(404, "Job not found")
if row["status"] != "hired":
raise HTTPException(400, "Feedback only accepted for hired jobs")
db.execute(
"UPDATE jobs SET hired_feedback = ? WHERE id = ?",
(json.dumps({"what_helped": payload.what_helped, "factors": payload.factors}), job_id),
)
db.commit()
db.close()
return {"ok": True}
# ── GET /api/contacts ──────────────────────────────────────────────────────────
@app.get("/api/contacts")
def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None,
search: Optional[str] = None, limit: int = 100, offset: int = 0):
db = _get_db()
query = """
SELECT jc.id, jc.job_id, jc.direction, jc.subject, jc.from_addr, jc.to_addr,
jc.received_at, jc.stage_signal,
j.title AS job_title, j.company AS job_company
FROM job_contacts jc
LEFT JOIN jobs j ON j.id = jc.job_id
WHERE 1=1
"""
params: list = []
if job_id is not None:
query += " AND jc.job_id = ?"
params.append(job_id)
if direction:
query += " AND jc.direction = ?"
params.append(direction)
if search:
query += " AND (jc.from_addr LIKE ? OR jc.to_addr LIKE ? OR jc.subject LIKE ?)"
like = f"%{search}%"
params += [like, like, like]
query += " ORDER BY jc.received_at DESC LIMIT ? OFFSET ?"
params += [limit, offset]
rows = db.execute(query, params).fetchall()
total = db.execute(
"SELECT COUNT(*) FROM job_contacts" + (" WHERE job_id = ?" if job_id else ""),
([job_id] if job_id else []),
).fetchone()[0]
db.close()
return {"total": total, "contacts": [dict(r) for r in rows]}
# ── GET /api/interviews ──────────────────────────────────────────────────────── # ── GET /api/interviews ────────────────────────────────────────────────────────
PIPELINE_STATUSES = { PIPELINE_STATUSES = {
@ -1509,7 +1590,7 @@ def list_interviews():
f"SELECT id, title, company, url, location, is_remote, salary, " f"SELECT id, title, company, url, location, is_remote, salary, "
f"match_score, keyword_gaps, status, " f"match_score, keyword_gaps, status, "
f"interview_date, rejection_stage, " f"interview_date, rejection_stage, "
f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at " f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at, hired_feedback "
f"FROM jobs WHERE status IN ({placeholders}) " f"FROM jobs WHERE status IN ({placeholders}) "
f"ORDER BY match_score DESC NULLS LAST", f"ORDER BY match_score DESC NULLS LAST",
list(PIPELINE_STATUSES), list(PIPELINE_STATUSES),

View file

@ -143,6 +143,8 @@ _MIGRATIONS = [
("calendar_event_id", "TEXT"), ("calendar_event_id", "TEXT"),
("optimized_resume", "TEXT"), # ATS-rewritten resume text (paid tier) ("optimized_resume", "TEXT"), # ATS-rewritten resume text (paid tier)
("ats_gap_report", "TEXT"), # JSON gap report (free tier) ("ats_gap_report", "TEXT"), # JSON gap report (free tier)
("date_posted", "TEXT"), # Original posting date from job board (shadow listing detection)
("hired_feedback", "TEXT"), # JSON: optional post-hire "what helped" response
] ]
@ -202,8 +204,8 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
try: try:
cursor = conn.execute( cursor = conn.execute(
"""INSERT INTO jobs """INSERT INTO jobs
(title, company, url, source, location, is_remote, salary, description, date_found) (title, company, url, source, location, is_remote, salary, description, date_found, date_posted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
job.get("title", ""), job.get("title", ""),
job.get("company", ""), job.get("company", ""),
@ -214,6 +216,7 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
job.get("salary", ""), job.get("salary", ""),
job.get("description", ""), job.get("description", ""),
job.get("date_found", ""), job.get("date_found", ""),
job.get("date_posted", "") or "",
), ),
) )
conn.commit() conn.commit()

View file

@ -307,6 +307,10 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
elif job_dict.get("salary_source") and str(job_dict["salary_source"]) not in ("nan", "None", ""): elif job_dict.get("salary_source") and str(job_dict["salary_source"]) not in ("nan", "None", ""):
salary_str = str(job_dict["salary_source"]) salary_str = str(job_dict["salary_source"])
_dp = job_dict.get("date_posted")
date_posted_str = (
_dp.isoformat() if hasattr(_dp, "isoformat") else str(_dp)
) if _dp and str(_dp) not in ("nan", "None", "") else ""
row = { row = {
"url": url, "url": url,
"title": _s(job_dict.get("title")), "title": _s(job_dict.get("title")),
@ -316,6 +320,7 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
"is_remote": bool(job_dict.get("is_remote", False)), "is_remote": bool(job_dict.get("is_remote", False)),
"salary": salary_str, "salary": salary_str,
"description": _s(job_dict.get("description")), "description": _s(job_dict.get("description")),
"date_posted": date_posted_str,
"_exclude_kw": exclude_kw, "_exclude_kw": exclude_kw,
} }
if _insert_if_new(row, _s(job_dict.get("site"))): if _insert_if_new(row, _s(job_dict.get("site"))):

View file

@ -96,6 +96,7 @@ import {
NewspaperIcon, NewspaperIcon,
Cog6ToothIcon, Cog6ToothIcon,
DocumentTextIcon, DocumentTextIcon,
UsersIcon,
} from '@heroicons/vue/24/outline' } from '@heroicons/vue/24/outline'
import { useDigestStore } from '../stores/digest' import { useDigestStore } from '../stores/digest'
@ -155,6 +156,7 @@ const navLinks = computed(() => [
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' }, { to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
{ to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' }, { to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' },
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' }, { to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
{ to: '/contacts', icon: UsersIcon, label: 'Contacts' },
{ to: '/digest', icon: NewspaperIcon, label: 'Digest', { to: '/digest', icon: NewspaperIcon, label: 'Digest',
badge: digestStore.entries.length || undefined }, badge: digestStore.entries.length || undefined },
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' }, { to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },

View file

@ -191,6 +191,37 @@ const columnColor = computed(() => {
} }
return map[props.job.status] ?? 'var(--color-border)' return map[props.job.status] ?? 'var(--color-border)'
}) })
// Hired feedback
const FEEDBACK_FACTORS = [
'Resume match',
'Cover letter',
'Interview prep',
'Company research',
'Network / referral',
'Salary negotiation',
] as const
const feedbackDismissed = ref(false)
const feedbackSaved = ref(!!props.job.hired_feedback)
const feedbackText = ref('')
const feedbackFactors = ref<string[]>([])
const feedbackSaving = ref(false)
const showFeedbackWidget = computed(() =>
props.job.status === 'hired' && !feedbackDismissed.value && !feedbackSaved.value
)
async function saveFeedback() {
feedbackSaving.value = true
const { error } = await useApiFetch(`/api/jobs/${props.job.id}/hired-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ what_helped: feedbackText.value, factors: feedbackFactors.value }),
})
feedbackSaving.value = false
if (!error) feedbackSaved.value = true
}
</script> </script>
<template> <template>
@ -307,6 +338,38 @@ const columnColor = computed(() => {
@click.stop="sigExpanded = !sigExpanded" @click.stop="sigExpanded = !sigExpanded"
>{{ sigExpanded ? ' less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button> >{{ sigExpanded ? ' less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
</template> </template>
<!-- Hired feedback widget -->
<div v-if="showFeedbackWidget" class="hired-feedback" @click.stop>
<div class="hired-feedback__header">
<span class="hired-feedback__title">What helped you land this role?</span>
<button class="hired-feedback__dismiss" @click="feedbackDismissed = true" aria-label="Dismiss feedback"></button>
</div>
<div class="hired-feedback__factors">
<label
v-for="factor in FEEDBACK_FACTORS"
:key="factor"
class="hired-feedback__factor"
>
<input type="checkbox" :value="factor" v-model="feedbackFactors" />
{{ factor }}
</label>
</div>
<textarea
v-model="feedbackText"
class="hired-feedback__textarea"
placeholder="Anything else that made the difference…"
rows="2"
/>
<button
class="hired-feedback__save"
:disabled="feedbackSaving"
@click="saveFeedback"
>{{ feedbackSaving ? 'Saving…' : 'Save reflection' }}</button>
</div>
<div v-else-if="job.status === 'hired' && feedbackSaved" class="hired-feedback hired-feedback--saved">
Reflection saved.
</div>
</article> </article>
</template> </template>
@ -533,4 +596,80 @@ const columnColor = computed(() => {
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer; background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
padding: 4px 12px; text-align: left; padding: 4px 12px; text-align: left;
} }
/* ── Hired feedback widget ── */
.hired-feedback {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border);
background: rgba(39, 174, 96, 0.04);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.hired-feedback--saved {
font-size: var(--text-sm);
color: var(--color-text-muted);
text-align: center;
padding: var(--space-2) var(--space-4);
}
.hired-feedback__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.hired-feedback__title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-success);
}
.hired-feedback__dismiss {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-sm);
padding: 2px 4px;
}
.hired-feedback__factors {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.hired-feedback__factor {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
color: var(--color-text-muted);
cursor: pointer;
}
.hired-feedback__textarea {
width: 100%;
font-size: var(--text-sm);
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
resize: vertical;
box-sizing: border-box;
}
.hired-feedback__textarea:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.hired-feedback__save {
align-self: flex-end;
padding: var(--space-1) var(--space-3);
font-size: var(--text-sm);
background: var(--color-success);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.hired-feedback__save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style> </style>

View file

@ -7,7 +7,7 @@
}" }"
:aria-label="`${job.title} at ${job.company}`" :aria-label="`${job.title} at ${job.company}`"
> >
<!-- Score badge + remote badge --> <!-- Score badge + remote badge + shadow badge -->
<div class="job-card__badges"> <div class="job-card__badges">
<span <span
v-if="job.match_score !== null" v-if="job.match_score !== null"
@ -18,6 +18,18 @@
{{ job.match_score }}% {{ job.match_score }}%
</span> </span>
<span v-if="job.is_remote" class="remote-badge">Remote</span> <span v-if="job.is_remote" class="remote-badge">Remote</span>
<span
v-if="job.shadow_score === 'shadow'"
class="shadow-badge shadow-badge--shadow"
:title="`Posted 30+ days before discovery — may already be filled`"
aria-label="Possible shadow listing: posted long before discovery"
>Ghost post</span>
<span
v-else-if="job.shadow_score === 'stale'"
class="shadow-badge shadow-badge--stale"
:title="`Posted 14+ days before discovery — listing may be stale`"
aria-label="Stale listing: posted over 2 weeks before discovery"
>Stale</span>
</div> </div>
<!-- Title + company --> <!-- Title + company -->
@ -178,6 +190,28 @@ const formattedDate = computed(() => {
color: var(--app-primary); color: var(--app-primary);
} }
.shadow-badge {
display: inline-flex;
align-items: center;
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
cursor: help;
}
.shadow-badge--shadow {
background: rgba(99, 99, 99, 0.15);
color: var(--color-text-muted);
border: 1px solid rgba(99, 99, 99, 0.3);
}
.shadow-badge--stale {
background: rgba(212, 137, 26, 0.12);
color: var(--score-mid);
border: 1px solid rgba(212, 137, 26, 0.25);
}
.job-card__title { .job-card__title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: var(--text-xl); font-size: var(--text-xl);

View file

@ -12,6 +12,7 @@ export const router = createRouter({
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') }, { path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
{ path: '/resumes', component: () => import('../views/ResumesView.vue') }, { path: '/resumes', component: () => import('../views/ResumesView.vue') },
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') }, { path: '/interviews', component: () => import('../views/InterviewsView.vue') },
{ path: '/contacts', component: () => import('../views/ContactsView.vue') },
{ path: '/digest', component: () => import('../views/DigestView.vue') }, { path: '/digest', component: () => import('../views/DigestView.vue') },
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') }, { path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') }, { path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },

View file

@ -30,6 +30,7 @@ export interface PipelineJob {
offer_at: string | null offer_at: string | null
hired_at: string | null hired_at: string | null
survey_at: string | null survey_at: string | null
hired_feedback: string | null // JSON: { what_helped, factors }
stage_signals: StageSignal[] // undismissed signals, newest first stage_signals: StageSignal[] // undismissed signals, newest first
} }

View file

@ -3,19 +3,21 @@ import { ref, computed } from 'vue'
import { useApiFetch } from '../composables/useApi' import { useApiFetch } from '../composables/useApi'
export interface Job { export interface Job {
id: number id: number
title: string title: string
company: string company: string
url: string url: string
source: string | null source: string | null
location: string | null location: string | null
is_remote: boolean is_remote: boolean
salary: string | null salary: string | null
description: string | null description: string | null
match_score: number | null match_score: number | null
keyword_gaps: string | null // JSON-encoded string[] keyword_gaps: string | null // JSON-encoded string[]
date_found: string date_found: string
status: string date_posted: string | null
shadow_score: 'shadow' | 'stale' | null
status: string
} }
interface UndoEntry { interface UndoEntry {

View file

@ -0,0 +1,333 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
interface Contact {
id: number
job_id: number
direction: 'inbound' | 'outbound'
subject: string | null
from_addr: string | null
to_addr: string | null
received_at: string | null
stage_signal: string | null
job_title: string | null
job_company: string | null
}
const contacts = ref<Contact[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const direction = ref<'all' | 'inbound' | 'outbound'>('all')
const searchInput = ref('')
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function fetchContacts() {
loading.value = true
error.value = null
const params = new URLSearchParams({ limit: '100' })
if (direction.value !== 'all') params.set('direction', direction.value)
if (search.value) params.set('search', search.value)
const { data, error: fetchErr } = await useApiFetch<{ total: number; contacts: Contact[] }>(
`/api/contacts?${params}`
)
loading.value = false
if (fetchErr || !data) {
error.value = 'Failed to load contacts.'
return
}
contacts.value = data.contacts
total.value = data.total
}
function onSearchInput() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
search.value = searchInput.value
fetchContacts()
}, 300)
}
function onDirectionChange() {
fetchContacts()
}
function formatDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
}
function displayAddr(contact: Contact): string {
return contact.direction === 'inbound'
? contact.from_addr ?? '—'
: contact.to_addr ?? '—'
}
const signalLabel: Record<string, string> = {
interview_scheduled: '📅 Interview',
offer_received: '🟢 Offer',
rejected: '✖ Rejected',
positive_response: '✅ Positive',
survey_received: '📋 Survey',
}
onMounted(fetchContacts)
</script>
<template>
<div class="contacts-view">
<header class="contacts-header">
<h1 class="contacts-title">Contacts</h1>
<span class="contacts-count" v-if="total > 0">{{ total }} total</span>
</header>
<div class="contacts-toolbar">
<input
v-model="searchInput"
class="contacts-search"
type="search"
placeholder="Search name, email, or subject…"
aria-label="Search contacts"
@input="onSearchInput"
/>
<div class="contacts-filter" role="group" aria-label="Filter by direction">
<button
v-for="opt in (['all', 'inbound', 'outbound'] as const)"
:key="opt"
class="filter-btn"
:class="{ 'filter-btn--active': direction === opt }"
@click="direction = opt; onDirectionChange()"
>{{ opt === 'all' ? 'All' : opt === 'inbound' ? 'Inbound' : 'Outbound' }}</button>
</div>
</div>
<div v-if="loading" class="contacts-empty">Loading</div>
<div v-else-if="error" class="contacts-empty contacts-empty--error">{{ error }}</div>
<div v-else-if="contacts.length === 0" class="contacts-empty">
No contacts found{{ search ? ' for that search' : '' }}.
</div>
<div v-else class="contacts-table-wrap">
<table class="contacts-table" aria-label="Contacts">
<thead>
<tr>
<th>Contact</th>
<th>Subject</th>
<th>Job</th>
<th>Signal</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr
v-for="c in contacts"
:key="c.id"
class="contacts-row"
:class="{ 'contacts-row--inbound': c.direction === 'inbound' }"
>
<td class="contacts-cell contacts-cell--addr">
<span class="dir-chip" :class="`dir-chip--${c.direction}`">
{{ c.direction === 'inbound' ? '↓' : '↑' }}
</span>
{{ displayAddr(c) }}
</td>
<td class="contacts-cell contacts-cell--subject">
{{ c.subject ? c.subject.slice(0, 60) + (c.subject.length > 60 ? '…' : '') : '—' }}
</td>
<td class="contacts-cell contacts-cell--job">
<span v-if="c.job_title">
{{ c.job_title }}<span v-if="c.job_company" class="job-company"> · {{ c.job_company }}</span>
</span>
<span v-else class="text-muted"></span>
</td>
<td class="contacts-cell contacts-cell--signal">
<span v-if="c.stage_signal && signalLabel[c.stage_signal]" class="signal-chip">
{{ signalLabel[c.stage_signal] }}
</span>
</td>
<td class="contacts-cell contacts-cell--date">{{ formatDate(c.received_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.contacts-view {
padding: var(--space-6);
max-width: 1000px;
}
.contacts-header {
display: flex;
align-items: baseline;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.contacts-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.contacts-count {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.contacts-toolbar {
display: flex;
gap: var(--space-3);
align-items: center;
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.contacts-search {
flex: 1;
min-width: 200px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
}
.contacts-search:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.contacts-filter {
display: flex;
gap: 4px;
}
.filter-btn {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text-muted);
font-size: var(--text-sm);
cursor: pointer;
}
.filter-btn--active {
background: var(--app-primary-light);
color: var(--app-primary);
border-color: var(--app-primary);
font-weight: 600;
}
.contacts-empty {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-8) 0;
text-align: center;
}
.contacts-empty--error {
color: var(--color-error, #c0392b);
}
.contacts-table-wrap {
overflow-x: auto;
}
.contacts-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.contacts-table th {
text-align: left;
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--color-border);
}
.contacts-row {
border-bottom: 1px solid var(--color-border);
}
.contacts-row:hover {
background: var(--color-hover);
}
.contacts-cell {
padding: var(--space-3);
vertical-align: top;
color: var(--color-text);
}
.contacts-cell--addr {
white-space: nowrap;
font-size: var(--text-xs);
font-family: var(--font-mono);
display: flex;
align-items: center;
gap: var(--space-2);
}
.contacts-cell--subject {
color: var(--color-text-muted);
}
.contacts-cell--job {
font-size: var(--text-xs);
}
.job-company {
color: var(--color-text-muted);
}
.contacts-cell--date {
white-space: nowrap;
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.dir-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
.dir-chip--inbound {
background: rgba(39, 174, 96, 0.15);
color: var(--color-success);
}
.dir-chip--outbound {
background: var(--app-primary-light);
color: var(--app-primary);
}
.signal-chip {
font-size: var(--text-xs);
white-space: nowrap;
}
.text-muted {
color: var(--color-text-muted);
}
</style>