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:
parent
6599bc6952
commit
0e4fce44c4
11 changed files with 622 additions and 21 deletions
|
|
@ -6,7 +6,7 @@ WORKDIR /app
|
|||
# System deps for companyScraper (beautifulsoup4, fake-useragent, lxml) and PDF gen
|
||||
# libsqlcipher-dev: required to build pysqlcipher3 (SQLCipher AES-256 encryption for cloud mode)
|
||||
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/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
|
|
|||
89
dev-api.py
89
dev-api.py
|
|
@ -16,7 +16,7 @@ import subprocess
|
|||
import sys
|
||||
import threading
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from urllib.parse import urlparse
|
||||
|
|
@ -230,6 +230,28 @@ def _row_to_job(row) -> dict:
|
|||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/jobs")
|
||||
|
|
@ -237,7 +259,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""):
|
|||
db = _get_db()
|
||||
rows = db.execute(
|
||||
"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 ?",
|
||||
(status, limit),
|
||||
).fetchall()
|
||||
|
|
@ -246,7 +268,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""):
|
|||
for r in rows:
|
||||
d = _row_to_job(r)
|
||||
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)
|
||||
result.append(d)
|
||||
return result
|
||||
|
|
@ -1490,6 +1512,65 @@ def suggest_qa_answer(job_id: int, payload: QASuggestPayload):
|
|||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
PIPELINE_STATUSES = {
|
||||
|
|
@ -1509,7 +1590,7 @@ def list_interviews():
|
|||
f"SELECT id, title, company, url, location, is_remote, salary, "
|
||||
f"match_score, keyword_gaps, status, "
|
||||
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"ORDER BY match_score DESC NULLS LAST",
|
||||
list(PIPELINE_STATUSES),
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ _MIGRATIONS = [
|
|||
("calendar_event_id", "TEXT"),
|
||||
("optimized_resume", "TEXT"), # ATS-rewritten resume text (paid 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:
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO jobs
|
||||
(title, company, url, source, location, is_remote, salary, description, date_found)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(title, company, url, source, location, is_remote, salary, description, date_found, date_posted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
job.get("title", ""),
|
||||
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("description", ""),
|
||||
job.get("date_found", ""),
|
||||
job.get("date_posted", "") or "",
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
|
|
|||
|
|
@ -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", ""):
|
||||
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 = {
|
||||
"url": url,
|
||||
"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)),
|
||||
"salary": salary_str,
|
||||
"description": _s(job_dict.get("description")),
|
||||
"date_posted": date_posted_str,
|
||||
"_exclude_kw": exclude_kw,
|
||||
}
|
||||
if _insert_if_new(row, _s(job_dict.get("site"))):
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ import {
|
|||
NewspaperIcon,
|
||||
Cog6ToothIcon,
|
||||
DocumentTextIcon,
|
||||
UsersIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
import { useDigestStore } from '../stores/digest'
|
||||
|
|
@ -155,6 +156,7 @@ const navLinks = computed(() => [
|
|||
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
|
||||
{ to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' },
|
||||
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
|
||||
{ to: '/contacts', icon: UsersIcon, label: 'Contacts' },
|
||||
{ to: '/digest', icon: NewspaperIcon, label: 'Digest',
|
||||
badge: digestStore.entries.length || undefined },
|
||||
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
|
||||
|
|
|
|||
|
|
@ -191,6 +191,37 @@ const columnColor = computed(() => {
|
|||
}
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -307,6 +338,38 @@ const columnColor = computed(() => {
|
|||
@click.stop="sigExpanded = !sigExpanded"
|
||||
>{{ sigExpanded ? '− less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
|
@ -533,4 +596,80 @@ const columnColor = computed(() => {
|
|||
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
}"
|
||||
:aria-label="`${job.title} at ${job.company}`"
|
||||
>
|
||||
<!-- Score badge + remote badge -->
|
||||
<!-- Score badge + remote badge + shadow badge -->
|
||||
<div class="job-card__badges">
|
||||
<span
|
||||
v-if="job.match_score !== null"
|
||||
|
|
@ -18,6 +18,18 @@
|
|||
{{ job.match_score }}%
|
||||
</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>
|
||||
|
||||
<!-- Title + company -->
|
||||
|
|
@ -178,6 +190,28 @@ const formattedDate = computed(() => {
|
|||
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 {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xl);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export const router = createRouter({
|
|||
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
|
||||
{ path: '/resumes', component: () => import('../views/ResumesView.vue') },
|
||||
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
|
||||
{ path: '/contacts', component: () => import('../views/ContactsView.vue') },
|
||||
{ path: '/digest', component: () => import('../views/DigestView.vue') },
|
||||
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
|
||||
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface PipelineJob {
|
|||
offer_at: string | null
|
||||
hired_at: string | null
|
||||
survey_at: string | null
|
||||
hired_feedback: string | null // JSON: { what_helped, factors }
|
||||
stage_signals: StageSignal[] // undismissed signals, newest first
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,21 @@ import { ref, computed } from 'vue'
|
|||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
export interface Job {
|
||||
id: number
|
||||
title: string
|
||||
company: string
|
||||
url: string
|
||||
source: string | null
|
||||
location: string | null
|
||||
is_remote: boolean
|
||||
salary: string | null
|
||||
description: string | null
|
||||
match_score: number | null
|
||||
keyword_gaps: string | null // JSON-encoded string[]
|
||||
date_found: string
|
||||
status: string
|
||||
id: number
|
||||
title: string
|
||||
company: string
|
||||
url: string
|
||||
source: string | null
|
||||
location: string | null
|
||||
is_remote: boolean
|
||||
salary: string | null
|
||||
description: string | null
|
||||
match_score: number | null
|
||||
keyword_gaps: string | null // JSON-encoded string[]
|
||||
date_found: string
|
||||
date_posted: string | null
|
||||
shadow_score: 'shadow' | 'stale' | null
|
||||
status: string
|
||||
}
|
||||
|
||||
interface UndoEntry {
|
||||
|
|
|
|||
333
web/src/views/ContactsView.vue
Normal file
333
web/src/views/ContactsView.vue
Normal 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>
|
||||
Loading…
Reference in a new issue