Compare commits
7 commits
8e36863a49
...
797032bd97
| Author | SHA1 | Date | |
|---|---|---|---|
| 797032bd97 | |||
| fb8b464dd0 | |||
| ec521e14c5 | |||
| a302049f72 | |||
| 03b9e52301 | |||
| 0e4fce44c4 | |||
| 6599bc6952 |
15 changed files with 1378 additions and 82 deletions
35
.github/workflows/ci.yml
vendored
35
.github/workflows/ci.yml
vendored
|
|
@ -1,35 +0,0 @@
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: sudo apt-get update -q && sudo apt-get install -y libsqlcipher-dev
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.11"
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Configure git credentials for Forgejo
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
|
||||||
run: |
|
|
||||||
git config --global url."https://oauth2:${FORGEJO_TOKEN}@git.opensourcesolarpunk.com/".insteadOf "https://git.opensourcesolarpunk.com/"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -r requirements.txt
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: pytest tests/ -v --tb=short
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -40,8 +40,11 @@ pytest-output.txt
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
|
||||||
data/email_score.jsonl
|
data/email_score.jsonl
|
||||||
|
data/email_score.jsonl.bad-labels
|
||||||
data/email_label_queue.jsonl
|
data/email_label_queue.jsonl
|
||||||
data/email_compare_sample.jsonl
|
data/email_compare_sample.jsonl
|
||||||
|
data/.feedback_ratelimit.json
|
||||||
|
data/config/
|
||||||
|
|
||||||
config/label_tool.yaml
|
config/label_tool.yaml
|
||||||
config/server.yaml
|
config/server.yaml
|
||||||
|
|
|
||||||
|
|
@ -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 .
|
||||||
|
|
|
||||||
353
dev-api.py
353
dev-api.py
|
|
@ -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
|
||||||
|
|
@ -58,6 +58,20 @@ async def lifespan(app: FastAPI):
|
||||||
_load_env(PEREGRINE_ROOT / ".env")
|
_load_env(PEREGRINE_ROOT / ".env")
|
||||||
from scripts.db_migrate import migrate_db
|
from scripts.db_migrate import migrate_db
|
||||||
migrate_db(Path(DB_PATH))
|
migrate_db(Path(DB_PATH))
|
||||||
|
|
||||||
|
# Cloud mode: sweep all known user DBs at startup so schema changes land
|
||||||
|
# for every user on deploy, not only on their next request.
|
||||||
|
if _CLOUD_MODE and _CLOUD_DATA_ROOT.is_dir():
|
||||||
|
import logging as _log
|
||||||
|
_sweep_log = _log.getLogger("peregrine.startup")
|
||||||
|
for user_db in _CLOUD_DATA_ROOT.glob("*/peregrine/staging.db"):
|
||||||
|
try:
|
||||||
|
migrate_db(user_db)
|
||||||
|
_migrated_db_paths.add(str(user_db))
|
||||||
|
_sweep_log.info("Migrated user DB: %s", user_db)
|
||||||
|
except Exception as exc:
|
||||||
|
_sweep_log.warning("Migration failed for %s: %s", user_db, exc)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -230,6 +244,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 +273,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 +282,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
|
||||||
|
|
@ -737,32 +773,17 @@ async def import_resume_endpoint(file: UploadFile, name: str = ""):
|
||||||
text = content.decode("utf-8", errors="replace")
|
text = content.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
elif ext in (".pdf", ".docx", ".odt"):
|
elif ext in (".pdf", ".docx", ".odt"):
|
||||||
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
|
from scripts.resume_parser import (
|
||||||
tmp.write(content)
|
extract_text_from_pdf as _extract_pdf,
|
||||||
tmp_path = tmp.name
|
extract_text_from_docx as _extract_docx,
|
||||||
try:
|
extract_text_from_odt as _extract_odt,
|
||||||
|
)
|
||||||
if ext == ".pdf":
|
if ext == ".pdf":
|
||||||
import pdfplumber
|
text = _extract_pdf(content)
|
||||||
with pdfplumber.open(tmp_path) as pdf:
|
|
||||||
text = "\n".join(p.extract_text() or "" for p in pdf.pages)
|
|
||||||
elif ext == ".docx":
|
elif ext == ".docx":
|
||||||
from docx import Document
|
text = _extract_docx(content)
|
||||||
doc = Document(tmp_path)
|
|
||||||
text = "\n".join(p.text for p in doc.paragraphs)
|
|
||||||
else:
|
else:
|
||||||
import zipfile
|
text = _extract_odt(content)
|
||||||
from xml.etree import ElementTree as ET
|
|
||||||
with zipfile.ZipFile(tmp_path) as z:
|
|
||||||
xml = z.read("content.xml")
|
|
||||||
ET_root = ET.fromstring(xml)
|
|
||||||
text = "\n".join(
|
|
||||||
el.text or ""
|
|
||||||
for el in ET_root.iter(
|
|
||||||
"{urn:oasis:names:tc:opendocument:xmlns:text:1.0}p"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
|
|
||||||
elif ext in (".yaml", ".yml"):
|
elif ext in (".yaml", ".yml"):
|
||||||
import yaml as _yaml
|
import yaml as _yaml
|
||||||
|
|
@ -1490,6 +1511,284 @@ 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]}
|
||||||
|
|
||||||
|
|
||||||
|
# ── References ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ReferencePayload(BaseModel):
|
||||||
|
name: str
|
||||||
|
relationship: str = ""
|
||||||
|
company: str = ""
|
||||||
|
email: str = ""
|
||||||
|
phone: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
tags: list[str] = []
|
||||||
|
|
||||||
|
class PrepEmailPayload(BaseModel):
|
||||||
|
job_id: int
|
||||||
|
|
||||||
|
class RecLetterPayload(BaseModel):
|
||||||
|
job_id: int
|
||||||
|
talking_points: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/references")
|
||||||
|
def list_references():
|
||||||
|
db = _get_db()
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT * FROM references_ ORDER BY name ASC"
|
||||||
|
).fetchall()
|
||||||
|
db.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/references")
|
||||||
|
def create_reference(payload: ReferencePayload):
|
||||||
|
db = _get_db()
|
||||||
|
cur = db.execute(
|
||||||
|
"""INSERT INTO references_ (name, relationship, company, email, phone, notes, tags)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(payload.name, payload.relationship, payload.company,
|
||||||
|
payload.email, payload.phone, payload.notes,
|
||||||
|
json.dumps(payload.tags)),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
row = db.execute("SELECT * FROM references_ WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||||
|
db.close()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/references/{ref_id}")
|
||||||
|
def update_reference(ref_id: int, payload: ReferencePayload):
|
||||||
|
db = _get_db()
|
||||||
|
row = db.execute("SELECT id FROM references_ WHERE id = ?", (ref_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Reference not found")
|
||||||
|
db.execute(
|
||||||
|
"""UPDATE references_ SET name=?, relationship=?, company=?, email=?, phone=?,
|
||||||
|
notes=?, tags=?, updated_at=datetime('now') WHERE id=?""",
|
||||||
|
(payload.name, payload.relationship, payload.company,
|
||||||
|
payload.email, payload.phone, payload.notes,
|
||||||
|
json.dumps(payload.tags), ref_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
updated = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
|
||||||
|
db.close()
|
||||||
|
return dict(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/references/{ref_id}")
|
||||||
|
def delete_reference(ref_id: int):
|
||||||
|
db = _get_db()
|
||||||
|
db.execute("DELETE FROM references_ WHERE id = ?", (ref_id,))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/references/for-job/{job_id}")
|
||||||
|
def references_for_job(job_id: int):
|
||||||
|
db = _get_db()
|
||||||
|
rows = db.execute(
|
||||||
|
"""SELECT r.*, jr.prep_email, jr.rec_letter, jr.id AS jr_id
|
||||||
|
FROM references_ r
|
||||||
|
JOIN job_references jr ON jr.reference_id = r.id
|
||||||
|
WHERE jr.job_id = ?
|
||||||
|
ORDER BY r.name ASC""",
|
||||||
|
(job_id,),
|
||||||
|
).fetchall()
|
||||||
|
db.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/references/{ref_id}/link-job")
|
||||||
|
def link_reference_to_job(ref_id: int, body: PrepEmailPayload):
|
||||||
|
db = _get_db()
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO job_references (job_id, reference_id) VALUES (?, ?)",
|
||||||
|
(body.job_id, ref_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass # already linked
|
||||||
|
db.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/references/{ref_id}/unlink-job/{job_id}")
|
||||||
|
def unlink_reference_from_job(ref_id: int, job_id: int):
|
||||||
|
db = _get_db()
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM job_references WHERE reference_id = ? AND job_id = ?",
|
||||||
|
(ref_id, job_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/references/{ref_id}/prep-email")
|
||||||
|
def generate_prep_email(ref_id: int, payload: PrepEmailPayload):
|
||||||
|
"""Draft a short 'heads up' email to send a reference before they hear from the hiring team."""
|
||||||
|
db = _get_db()
|
||||||
|
ref_row = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
|
||||||
|
if not ref_row:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(404, "Reference not found")
|
||||||
|
job_row = db.execute(
|
||||||
|
"SELECT title, company, description FROM jobs WHERE id = ?", (payload.job_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not job_row:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(404, "Job not found")
|
||||||
|
ref = dict(ref_row)
|
||||||
|
job = dict(job_row)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
prompt = f"""Draft a short, warm email to send to a professional reference before a job interview.
|
||||||
|
|
||||||
|
Reference: {ref['name']} ({ref['relationship']} at {ref['company']})
|
||||||
|
Role applying for: {job['title']} at {job['company']}
|
||||||
|
Job description excerpt: {(job['description'] or '')[:500]}
|
||||||
|
|
||||||
|
The email should:
|
||||||
|
- Be 3-4 short paragraphs max
|
||||||
|
- Thank them for being a reference
|
||||||
|
- Briefly describe the role and why it's a good fit
|
||||||
|
- Mention 1-2 specific accomplishments they could speak to
|
||||||
|
- Give them a heads-up they may be contacted soon
|
||||||
|
- Be warm and professional, not overly formal
|
||||||
|
|
||||||
|
Return only the email body (no subject line)."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scripts.llm_router import LLMRouter
|
||||||
|
router = LLMRouter()
|
||||||
|
email_text = router.complete(prompt)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"LLM generation failed: {e}")
|
||||||
|
|
||||||
|
# Persist to job_references
|
||||||
|
db = _get_db()
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO job_references (job_id, reference_id, prep_email)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(job_id, reference_id) DO UPDATE SET prep_email = excluded.prep_email""",
|
||||||
|
(payload.job_id, ref_id, email_text),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"prep_email": email_text}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/references/{ref_id}/rec-letter")
|
||||||
|
def generate_rec_letter(ref_id: int, payload: RecLetterPayload):
|
||||||
|
"""Draft a recommendation letter the reference can edit and send on their letterhead."""
|
||||||
|
db = _get_db()
|
||||||
|
ref_row = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
|
||||||
|
if not ref_row:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(404, "Reference not found")
|
||||||
|
job_row = db.execute(
|
||||||
|
"SELECT title, company, description FROM jobs WHERE id = ?", (payload.job_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not job_row:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(404, "Job not found")
|
||||||
|
ref = dict(ref_row)
|
||||||
|
job = dict(job_row)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
prompt = f"""Draft a professional recommendation letter that {ref['name']} ({ref['relationship']}) could send on their letterhead for a candidate applying to {job['title']} at {job['company']}.
|
||||||
|
|
||||||
|
Key talking points to highlight: {payload.talking_points or 'general professional excellence, collaboration, initiative'}
|
||||||
|
|
||||||
|
The letter should:
|
||||||
|
- Be addressed generically (Dear Hiring Manager)
|
||||||
|
- Be 3-4 paragraphs
|
||||||
|
- Sound natural — written from the recommender's voice, not the candidate's
|
||||||
|
- Highlight specific, credible observations a {ref['relationship']} would have
|
||||||
|
- Close with strong endorsement and contact offer
|
||||||
|
|
||||||
|
Return only the letter body."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scripts.llm_router import LLMRouter
|
||||||
|
router = LLMRouter()
|
||||||
|
letter_text = router.complete(prompt)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"LLM generation failed: {e}")
|
||||||
|
|
||||||
|
db = _get_db()
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO job_references (job_id, reference_id, rec_letter)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(job_id, reference_id) DO UPDATE SET rec_letter = excluded.rec_letter""",
|
||||||
|
(payload.job_id, ref_id, letter_text),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"rec_letter": letter_text}
|
||||||
|
|
||||||
|
|
||||||
# ── GET /api/interviews ────────────────────────────────────────────────────────
|
# ── GET /api/interviews ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
PIPELINE_STATUSES = {
|
PIPELINE_STATUSES = {
|
||||||
|
|
@ -1509,7 +1808,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),
|
||||||
|
|
|
||||||
6
migrations/006_date_posted.sql
Normal file
6
migrations/006_date_posted.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- 006_date_posted.sql
|
||||||
|
-- Add date_posted column for shadow listing detection (stale/shadow score feature).
|
||||||
|
-- New DBs already have this column from the CREATE TABLE statement in db.py;
|
||||||
|
-- this migration adds it to existing user DBs.
|
||||||
|
|
||||||
|
ALTER TABLE jobs ADD COLUMN date_posted TEXT;
|
||||||
|
|
@ -130,6 +130,32 @@ CREATE TABLE IF NOT EXISTS digest_queue (
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CREATE_REFERENCES = """
|
||||||
|
CREATE TABLE IF NOT EXISTS references_ (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
relationship TEXT,
|
||||||
|
company TEXT,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
tags TEXT DEFAULT '[]',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
CREATE_JOB_REFERENCES = """
|
||||||
|
CREATE TABLE IF NOT EXISTS job_references (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||||
|
reference_id INTEGER NOT NULL REFERENCES references_(id) ON DELETE CASCADE,
|
||||||
|
prep_email TEXT,
|
||||||
|
rec_letter TEXT,
|
||||||
|
UNIQUE(job_id, reference_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
_MIGRATIONS = [
|
_MIGRATIONS = [
|
||||||
("cover_letter", "TEXT"),
|
("cover_letter", "TEXT"),
|
||||||
("applied_at", "TEXT"),
|
("applied_at", "TEXT"),
|
||||||
|
|
@ -143,6 +169,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
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -176,6 +204,9 @@ def _migrate_db(db_path: Path) -> None:
|
||||||
conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT")
|
conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass # column already exists
|
pass # column already exists
|
||||||
|
# Ensure references tables exist (CREATE IF NOT EXISTS is idempotent)
|
||||||
|
conn.execute(CREATE_REFERENCES)
|
||||||
|
conn.execute(CREATE_JOB_REFERENCES)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -189,6 +220,8 @@ def init_db(db_path: Path = DEFAULT_DB) -> None:
|
||||||
conn.execute(CREATE_BACKGROUND_TASKS)
|
conn.execute(CREATE_BACKGROUND_TASKS)
|
||||||
conn.execute(CREATE_SURVEY_RESPONSES)
|
conn.execute(CREATE_SURVEY_RESPONSES)
|
||||||
conn.execute(CREATE_DIGEST_QUEUE)
|
conn.execute(CREATE_DIGEST_QUEUE)
|
||||||
|
conn.execute(CREATE_REFERENCES)
|
||||||
|
conn.execute(CREATE_JOB_REFERENCES)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
_migrate_db(db_path)
|
_migrate_db(db_path)
|
||||||
|
|
@ -202,8 +235,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 +247,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()
|
||||||
|
|
|
||||||
|
|
@ -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"))):
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,8 @@ import {
|
||||||
NewspaperIcon,
|
NewspaperIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
|
UsersIcon,
|
||||||
|
IdentificationIcon,
|
||||||
} from '@heroicons/vue/24/outline'
|
} from '@heroicons/vue/24/outline'
|
||||||
|
|
||||||
import { useDigestStore } from '../stores/digest'
|
import { useDigestStore } from '../stores/digest'
|
||||||
|
|
@ -155,6 +157,8 @@ 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: '/references', icon: IdentificationIcon, label: 'References' },
|
||||||
{ 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' },
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ 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: '/references', component: () => import('../views/ReferencesView.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') },
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ export interface Job {
|
||||||
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
|
||||||
|
date_posted: string | null
|
||||||
|
shadow_score: 'shadow' | 'stale' | null
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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>
|
||||||
469
web/src/views/ReferencesView.vue
Normal file
469
web/src/views/ReferencesView.vue
Normal file
|
|
@ -0,0 +1,469 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
|
||||||
|
export interface Reference {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
relationship: string
|
||||||
|
company: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
notes: string
|
||||||
|
tags: string // JSON-encoded string[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_OPTIONS = ['technical', 'managerial', 'character', 'academic'] as const
|
||||||
|
|
||||||
|
const references = ref<Reference[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const editingId = ref<number | 'new' | null>(null)
|
||||||
|
const deleteConfirmId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const blankForm = () => ({
|
||||||
|
name: '', relationship: '', company: '', email: '', phone: '', notes: '', tags: [] as string[],
|
||||||
|
})
|
||||||
|
const form = ref(blankForm())
|
||||||
|
|
||||||
|
async function fetchRefs() {
|
||||||
|
loading.value = true
|
||||||
|
const { data } = await useApiFetch<Reference[]>('/api/references')
|
||||||
|
loading.value = false
|
||||||
|
if (data) references.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTags(raw: string): string[] {
|
||||||
|
try { return JSON.parse(raw) } catch { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNew() {
|
||||||
|
form.value = blankForm()
|
||||||
|
editingId.value = 'new'
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(ref: Reference) {
|
||||||
|
form.value = {
|
||||||
|
name: ref.name,
|
||||||
|
relationship: ref.relationship,
|
||||||
|
company: ref.company,
|
||||||
|
email: ref.email,
|
||||||
|
phone: ref.phone,
|
||||||
|
notes: ref.notes,
|
||||||
|
tags: parseTags(ref.tags),
|
||||||
|
}
|
||||||
|
editingId.value = ref.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId.value = null
|
||||||
|
form.value = blankForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRef() {
|
||||||
|
if (!form.value.name.trim()) return
|
||||||
|
saving.value = true
|
||||||
|
const isNew = editingId.value === 'new'
|
||||||
|
const url = isNew ? '/api/references' : `/api/references/${editingId.value}`
|
||||||
|
const { data, error } = await useApiFetch<Reference>(url, {
|
||||||
|
method: isNew ? 'POST' : 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form.value),
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
if (error || !data) return
|
||||||
|
if (isNew) {
|
||||||
|
references.value = [...references.value, data]
|
||||||
|
} else {
|
||||||
|
references.value = references.value.map(r => r.id === data.id ? data : r)
|
||||||
|
}
|
||||||
|
cancelEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRef(id: number) {
|
||||||
|
await useApiFetch(`/api/references/${id}`, { method: 'DELETE' })
|
||||||
|
references.value = references.value.filter(r => r.id !== id)
|
||||||
|
deleteConfirmId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagLabel(tag: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
technical: 'Technical', managerial: 'Manager', character: 'Character', academic: 'Academic',
|
||||||
|
}
|
||||||
|
return map[tag] ?? tag
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchRefs)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="refs-view">
|
||||||
|
<header class="refs-header">
|
||||||
|
<div class="refs-header__left">
|
||||||
|
<h1 class="refs-title">References</h1>
|
||||||
|
<span v-if="references.length" class="refs-count">{{ references.length }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-add" @click="startNew" :disabled="editingId !== null">
|
||||||
|
+ Add reference
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="refs-hint">
|
||||||
|
Store your references once, associate them with applications, and generate
|
||||||
|
a heads-up email or draft recommendation letter with one click.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- New / edit form -->
|
||||||
|
<div v-if="editingId !== null" class="ref-form">
|
||||||
|
<h2 class="ref-form__title">{{ editingId === 'new' ? 'Add reference' : 'Edit reference' }}</h2>
|
||||||
|
<div class="ref-form__grid">
|
||||||
|
<label class="ref-form__field ref-form__field--full">
|
||||||
|
<span>Name <span class="required">*</span></span>
|
||||||
|
<input v-model="form.name" class="ref-input" placeholder="Full name" />
|
||||||
|
</label>
|
||||||
|
<label class="ref-form__field">
|
||||||
|
<span>Relationship</span>
|
||||||
|
<input v-model="form.relationship" class="ref-input" placeholder="e.g. Former manager" />
|
||||||
|
</label>
|
||||||
|
<label class="ref-form__field">
|
||||||
|
<span>Company</span>
|
||||||
|
<input v-model="form.company" class="ref-input" placeholder="Where you worked together" />
|
||||||
|
</label>
|
||||||
|
<label class="ref-form__field">
|
||||||
|
<span>Email</span>
|
||||||
|
<input v-model="form.email" class="ref-input" type="email" placeholder="email@example.com" />
|
||||||
|
</label>
|
||||||
|
<label class="ref-form__field">
|
||||||
|
<span>Phone</span>
|
||||||
|
<input v-model="form.phone" class="ref-input" type="tel" placeholder="+1 555 000 0000" />
|
||||||
|
</label>
|
||||||
|
<label class="ref-form__field ref-form__field--full">
|
||||||
|
<span>Notes</span>
|
||||||
|
<textarea v-model="form.notes" class="ref-input ref-textarea"
|
||||||
|
placeholder="Accomplishments they can speak to, context, anything to remind yourself…"
|
||||||
|
rows="2" />
|
||||||
|
</label>
|
||||||
|
<div class="ref-form__field ref-form__field--full">
|
||||||
|
<span>Type</span>
|
||||||
|
<div class="ref-tags">
|
||||||
|
<label v-for="t in TAG_OPTIONS" :key="t" class="ref-tag-option">
|
||||||
|
<input type="checkbox" :value="t" v-model="form.tags" />
|
||||||
|
{{ tagLabel(t) }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ref-form__actions">
|
||||||
|
<button class="btn-save" @click="saveRef" :disabled="saving || !form.name.trim()">
|
||||||
|
{{ saving ? 'Saving…' : editingId === 'new' ? 'Add' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-cancel" @click="cancelEdit">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="!loading && references.length === 0 && editingId === null" class="refs-empty">
|
||||||
|
No references yet. Add someone who can speak to your work.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reference cards -->
|
||||||
|
<ul v-else class="refs-list" aria-label="References">
|
||||||
|
<li
|
||||||
|
v-for="ref in references"
|
||||||
|
:key="ref.id"
|
||||||
|
class="ref-card"
|
||||||
|
:class="{ 'ref-card--editing': editingId === ref.id }"
|
||||||
|
>
|
||||||
|
<div class="ref-card__body">
|
||||||
|
<div class="ref-card__name">{{ ref.name }}</div>
|
||||||
|
<div class="ref-card__meta">
|
||||||
|
<span v-if="ref.relationship">{{ ref.relationship }}</span>
|
||||||
|
<span v-if="ref.relationship && ref.company" class="sep"> · </span>
|
||||||
|
<span v-if="ref.company">{{ ref.company }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ref-card__contact">
|
||||||
|
<a v-if="ref.email" :href="`mailto:${ref.email}`" class="ref-link">{{ ref.email }}</a>
|
||||||
|
<span v-if="ref.email && ref.phone" class="sep"> · </span>
|
||||||
|
<span v-if="ref.phone">{{ ref.phone }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="ref.notes" class="ref-card__notes">{{ ref.notes }}</div>
|
||||||
|
<div v-if="parseTags(ref.tags).length" class="ref-card__tags">
|
||||||
|
<span v-for="t in parseTags(ref.tags)" :key="t" class="tag-chip" :class="`tag-chip--${t}`">
|
||||||
|
{{ tagLabel(t) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ref-card__actions">
|
||||||
|
<button class="btn-ghost" @click="startEdit(ref)" :disabled="editingId !== null">Edit</button>
|
||||||
|
<button
|
||||||
|
v-if="deleteConfirmId !== ref.id"
|
||||||
|
class="btn-ghost btn-ghost--danger"
|
||||||
|
@click="deleteConfirmId = ref.id"
|
||||||
|
:disabled="editingId !== null"
|
||||||
|
>Delete</button>
|
||||||
|
<template v-else>
|
||||||
|
<button class="btn-ghost btn-ghost--danger" @click="deleteRef(ref.id)">Confirm</button>
|
||||||
|
<button class="btn-ghost" @click="deleteConfirmId = null">Cancel</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.refs-view {
|
||||||
|
padding: var(--space-6);
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-header__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-title {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-count {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-hint {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 var(--space-5);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--app-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.ref-form {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: var(--space-5);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-form__title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-form__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-form__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-form__field--full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
.required { color: var(--color-error, #c0392b); }
|
||||||
|
|
||||||
|
.ref-input {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-input:focus-visible {
|
||||||
|
outline: 2px solid var(--app-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-textarea { resize: vertical; font-family: inherit; }
|
||||||
|
|
||||||
|
.ref-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-tag-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-form__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--app-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty */
|
||||||
|
.refs-empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: var(--space-8) 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List */
|
||||||
|
.refs-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-card {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-card--editing {
|
||||||
|
border-color: var(--app-primary);
|
||||||
|
box-shadow: 0 0 0 2px var(--app-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-card__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-card__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-card__meta, .ref-card__contact {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-card__notes {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-link {
|
||||||
|
color: var(--app-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.sep { margin: 0 2px; }
|
||||||
|
|
||||||
|
.ref-card__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip--technical { background: var(--app-primary-light); color: var(--app-primary); }
|
||||||
|
.tag-chip--managerial { background: rgba(39, 174, 96, 0.12); color: var(--color-success); }
|
||||||
|
.tag-chip--character { background: rgba(212, 137, 26, 0.12); color: var(--score-mid); }
|
||||||
|
.tag-chip--academic { background: rgba(103, 58, 183, 0.12); color: #7c3aed; }
|
||||||
|
|
||||||
|
.ref-card__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover { background: var(--color-hover); }
|
||||||
|
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.btn-ghost--danger { color: var(--color-error, #c0392b); border-color: rgba(192, 57, 43, 0.3); }
|
||||||
|
.btn-ghost--danger:hover { background: rgba(192, 57, 43, 0.07); }
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue