feat(vue-spa): Apply view — job picker list + cover letter workspace
- router: add /apply/:id → ApplyWorkspaceView (lazy-loaded) - ApplyView.vue: approved job list sorted by match score; badges for match %, remote, and cover-letter draft status; links to workspace - ApplyWorkspaceView.vue: two-panel desktop layout (sticky job details left, editor right); cover letter state machine (none/queued/running/ ready/failed); auto-resize textarea; word count toolbar; Save button with dirty tracking; Download PDF (programmatic <a> click, named file); Generate with AI + Retry; Mark as Applied + Reject Listing actions; polling loop for in-flight generation tasks; toast on action - HomeView.vue: split combined "Archive Pending + Rejected" into three separate per-status archive buttons (only shown when count > 0) - dev-api.py: add GET /api/jobs/:id, POST /api/jobs/:id/applied, PATCH /api/jobs/:id/cover_letter, POST .../cover_letter/generate (wires submit_task), GET .../cover_letter/task (poll), GET .../pdf (reportlab); has_cover_letter field on list + detail responses
This commit is contained in:
parent
ffec6bb843
commit
2384345d54
5 changed files with 1263 additions and 27 deletions
160
dev-api.py
160
dev-api.py
|
|
@ -6,12 +6,22 @@ Run with:
|
|||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import json
|
||||
from fastapi import FastAPI, HTTPException
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, HTTPException, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
# Allow importing peregrine scripts for cover letter generation
|
||||
PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine")
|
||||
if str(PEREGRINE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PEREGRINE_ROOT))
|
||||
|
||||
DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
|
||||
|
||||
app = FastAPI(title="Peregrine Dev API")
|
||||
|
|
@ -39,16 +49,23 @@ def _row_to_job(row) -> dict:
|
|||
# ── GET /api/jobs ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/jobs")
|
||||
def list_jobs(status: str = "pending", limit: int = 50):
|
||||
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 "
|
||||
"description, match_score, keyword_gaps, date_found, status, cover_letter "
|
||||
"FROM jobs WHERE status = ? ORDER BY match_score DESC NULLS LAST LIMIT ?",
|
||||
(status, limit),
|
||||
).fetchall()
|
||||
db.close()
|
||||
return [_row_to_job(r) for r in rows]
|
||||
result = []
|
||||
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.pop("cover_letter", None)
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
# ── GET /api/jobs/counts ──────────────────────────────────────────────────────
|
||||
|
|
@ -122,6 +139,141 @@ def system_status():
|
|||
}
|
||||
|
||||
|
||||
# ── GET /api/jobs/:id ────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
def get_job(job_id: int):
|
||||
db = _get_db()
|
||||
row = db.execute(
|
||||
"SELECT id, title, company, url, source, location, is_remote, salary, "
|
||||
"description, match_score, keyword_gaps, date_found, status, cover_letter "
|
||||
"FROM jobs WHERE id = ?",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
db.close()
|
||||
if not row:
|
||||
raise HTTPException(404, "Job not found")
|
||||
d = _row_to_job(row)
|
||||
d["has_cover_letter"] = bool(d.get("cover_letter"))
|
||||
return d
|
||||
|
||||
|
||||
# ── POST /api/jobs/:id/applied ────────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/jobs/{job_id}/applied")
|
||||
def mark_applied(job_id: int):
|
||||
db = _get_db()
|
||||
db.execute(
|
||||
"UPDATE jobs SET status = 'applied', applied_at = datetime('now') WHERE id = ?",
|
||||
(job_id,),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── PATCH /api/jobs/:id/cover_letter ─────────────────────────────────────────
|
||||
|
||||
class CoverLetterBody(BaseModel):
|
||||
text: str
|
||||
|
||||
@app.patch("/api/jobs/{job_id}/cover_letter")
|
||||
def save_cover_letter(job_id: int, body: CoverLetterBody):
|
||||
db = _get_db()
|
||||
db.execute("UPDATE jobs SET cover_letter = ? WHERE id = ?", (body.text, job_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── POST /api/jobs/:id/cover_letter/generate ─────────────────────────────────
|
||||
|
||||
@app.post("/api/jobs/{job_id}/cover_letter/generate")
|
||||
def generate_cover_letter(job_id: int):
|
||||
try:
|
||||
from scripts.task_runner import submit_task
|
||||
task_id, is_new = submit_task(
|
||||
db_path=Path(DB_PATH),
|
||||
task_type="cover_letter",
|
||||
job_id=job_id,
|
||||
)
|
||||
return {"task_id": task_id, "is_new": is_new}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
# ── GET /api/jobs/:id/cover_letter/task ──────────────────────────────────────
|
||||
|
||||
@app.get("/api/jobs/{job_id}/cover_letter/task")
|
||||
def cover_letter_task(job_id: int):
|
||||
db = _get_db()
|
||||
row = db.execute(
|
||||
"SELECT status, stage, error FROM background_tasks "
|
||||
"WHERE task_type = 'cover_letter' AND job_id = ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
db.close()
|
||||
if not row:
|
||||
return {"status": "none", "stage": None, "message": None}
|
||||
return {
|
||||
"status": row["status"],
|
||||
"stage": row["stage"],
|
||||
"message": row["error"],
|
||||
}
|
||||
|
||||
|
||||
# ── GET /api/jobs/:id/cover_letter/pdf ───────────────────────────────────────
|
||||
|
||||
@app.get("/api/jobs/{job_id}/cover_letter/pdf")
|
||||
def download_pdf(job_id: int):
|
||||
db = _get_db()
|
||||
row = db.execute(
|
||||
"SELECT title, company, cover_letter FROM jobs WHERE id = ?", (job_id,)
|
||||
).fetchone()
|
||||
db.close()
|
||||
if not row or not row["cover_letter"]:
|
||||
raise HTTPException(404, "No cover letter found")
|
||||
|
||||
try:
|
||||
from reportlab.lib.pagesizes import letter as letter_size
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib.colors import HexColor
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(buf, pagesize=letter_size,
|
||||
leftMargin=inch, rightMargin=inch,
|
||||
topMargin=inch, bottomMargin=inch)
|
||||
dark = HexColor("#1a2338")
|
||||
body_style = ParagraphStyle(
|
||||
"Body", fontName="Helvetica", fontSize=11,
|
||||
textColor=dark, leading=16, spaceAfter=12, alignment=TA_LEFT,
|
||||
)
|
||||
story = []
|
||||
for para in row["cover_letter"].split("\n\n"):
|
||||
para = para.strip()
|
||||
if para:
|
||||
story.append(Paragraph(para.replace("\n", "<br/>"), body_style))
|
||||
story.append(Spacer(1, 2))
|
||||
doc.build(story)
|
||||
|
||||
company_safe = re.sub(r"[^a-zA-Z0-9]", "", row["company"] or "Company")
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
filename = f"CoverLetter_{company_safe}_{date_str}.pdf"
|
||||
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
except ImportError:
|
||||
raise HTTPException(501, "reportlab not installed — install it to generate PDFs")
|
||||
|
||||
|
||||
# ── GET /api/config/user ──────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/config/user")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const router = createRouter({
|
|||
{ path: '/', component: () => import('../views/HomeView.vue') },
|
||||
{ path: '/review', component: () => import('../views/JobReviewView.vue') },
|
||||
{ path: '/apply', component: () => import('../views/ApplyView.vue') },
|
||||
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
|
||||
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
|
||||
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
|
||||
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
|
||||
|
|
|
|||
|
|
@ -1,18 +1,253 @@
|
|||
<template>
|
||||
<div class="view-placeholder">
|
||||
<h1>ApplyView</h1>
|
||||
<p class="placeholder-note">Vue port in progress — Streamlit equivalent at app/pages/</p>
|
||||
<div class="apply-list">
|
||||
<header class="apply-list__header">
|
||||
<h1 class="apply-list__title">Apply</h1>
|
||||
<p class="apply-list__subtitle">Approved jobs ready for applications</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="apply-list__loading" aria-live="polite">
|
||||
<span class="spinner" aria-hidden="true" />
|
||||
<span>Loading approved jobs…</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="jobs.length === 0" class="apply-list__empty" role="status">
|
||||
<span aria-hidden="true" class="empty-icon">📋</span>
|
||||
<h2 class="empty-title">No approved jobs yet</h2>
|
||||
<p class="empty-desc">Approve listings in Job Review, then come back here to write applications.</p>
|
||||
<RouterLink to="/review" class="empty-cta">Go to Job Review →</RouterLink>
|
||||
</div>
|
||||
|
||||
<ul v-else class="apply-list__jobs" role="list">
|
||||
<li v-for="job in jobs" :key="job.id">
|
||||
<RouterLink :to="`/apply/${job.id}`" class="job-row" :aria-label="`Open ${job.title} at ${job.company}`">
|
||||
<div class="job-row__main">
|
||||
<div class="job-row__badges">
|
||||
<span
|
||||
v-if="job.match_score !== null"
|
||||
class="score-badge"
|
||||
:class="scoreBadgeClass(job.match_score)"
|
||||
>{{ job.match_score }}%</span>
|
||||
<span v-if="job.is_remote" class="remote-badge">Remote</span>
|
||||
<span v-if="job.has_cover_letter" class="cl-badge cl-badge--done">✓ Draft</span>
|
||||
<span v-else class="cl-badge cl-badge--pending">○ No draft</span>
|
||||
</div>
|
||||
<span class="job-row__title">{{ job.title }}</span>
|
||||
<span class="job-row__company">
|
||||
{{ job.company }}
|
||||
<span v-if="job.location" class="job-row__sep" aria-hidden="true"> · </span>
|
||||
<span v-if="job.location">{{ job.location }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="job-row__meta">
|
||||
<span v-if="job.salary" class="job-row__salary">{{ job.salary }}</span>
|
||||
<span class="job-row__arrow" aria-hidden="true">›</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view-placeholder {
|
||||
padding: var(--space-8);
|
||||
max-width: 60ch;
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
interface ApprovedJob {
|
||||
id: number
|
||||
title: string
|
||||
company: string
|
||||
location: string | null
|
||||
is_remote: boolean
|
||||
salary: string | null
|
||||
match_score: number | null
|
||||
has_cover_letter: boolean
|
||||
}
|
||||
.placeholder-note {
|
||||
|
||||
const jobs = ref<ApprovedJob[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
function scoreBadgeClass(score: number) {
|
||||
if (score >= 80) return 'score-badge--high'
|
||||
if (score >= 60) return 'score-badge--mid'
|
||||
return 'score-badge--low'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await useApiFetch<ApprovedJob[]>('/api/jobs?status=approved&limit=100&fields=id,title,company,location,is_remote,salary,match_score,has_cover_letter')
|
||||
loading.value = false
|
||||
if (data) jobs.value = data
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.apply-list {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.apply-list__header { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
|
||||
.apply-list__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2xl);
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.apply-list__subtitle { font-size: var(--text-sm); color: var(--color-text-muted); }
|
||||
|
||||
.apply-list__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-12);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
font-size: var(--text-sm);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.apply-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-16) var(--space-8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 3rem; }
|
||||
.empty-title { font-family: var(--font-display); font-size: var(--text-xl); color: var(--color-text); }
|
||||
.empty-desc { font-size: var(--text-sm); color: var(--color-text-muted); max-width: 32ch; }
|
||||
|
||||
.empty-cta {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--app-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.empty-cta:hover { opacity: 0.7; }
|
||||
|
||||
.apply-list__jobs { list-style: none; display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
|
||||
.job-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
min-height: 72px;
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.job-row:hover {
|
||||
border-color: var(--app-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.job-row__main { display: flex; flex-direction: column; gap: var(--space-1); flex: 1; min-width: 0; }
|
||||
|
||||
.job-row__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.score-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
|
||||
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
|
||||
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
|
||||
|
||||
.remote-badge {
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
background: var(--app-primary-light);
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.cl-badge {
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cl-badge--done { background: rgba(39,174,96,0.10); color: var(--color-success); }
|
||||
.cl-badge--pending { background: var(--color-surface-alt); color: var(--color-text-muted); }
|
||||
|
||||
.job-row__title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-row__company {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-row__sep { color: var(--color-border); }
|
||||
|
||||
.job-row__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-row__salary {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-row__arrow {
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--app-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.apply-list { padding: var(--space-4); gap: var(--space-4); }
|
||||
.apply-list__title { font-size: var(--text-xl); }
|
||||
.job-row { padding: var(--space-3) var(--space-4); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
846
web/src/views/ApplyWorkspaceView.vue
Normal file
846
web/src/views/ApplyWorkspaceView.vue
Normal file
|
|
@ -0,0 +1,846 @@
|
|||
<template>
|
||||
<div class="workspace">
|
||||
<!-- Back nav -->
|
||||
<RouterLink to="/apply" class="workspace__back">← Back to Apply</RouterLink>
|
||||
|
||||
<div v-if="loadingJob" class="workspace__loading" aria-live="polite">
|
||||
<span class="spinner" aria-hidden="true" />
|
||||
<span>Loading job…</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!job" class="workspace__not-found" role="alert">
|
||||
<p>Job not found.</p>
|
||||
<RouterLink to="/apply" class="btn-ghost">← Back</RouterLink>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Two-panel layout: job details | cover letter -->
|
||||
<div class="workspace__panels">
|
||||
|
||||
<!-- ── Left: Job details ──────────────────────────────────────── -->
|
||||
<aside class="workspace__job-panel">
|
||||
<div class="job-details">
|
||||
<!-- Badges -->
|
||||
<div class="job-details__badges">
|
||||
<span v-if="job.match_score !== null" class="score-badge" :class="scoreBadgeClass">
|
||||
{{ job.match_score }}%
|
||||
</span>
|
||||
<span v-if="job.is_remote" class="remote-badge">Remote</span>
|
||||
</div>
|
||||
|
||||
<h1 class="job-details__title">{{ job.title }}</h1>
|
||||
<div class="job-details__company">
|
||||
{{ job.company }}
|
||||
<span v-if="job.location" aria-hidden="true"> · </span>
|
||||
<span v-if="job.location" class="job-details__location">{{ job.location }}</span>
|
||||
</div>
|
||||
<div v-if="job.salary" class="job-details__salary">{{ job.salary }}</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="job-details__desc" :class="{ 'job-details__desc--clamped': !descExpanded }">
|
||||
{{ job.description ?? 'No description available.' }}
|
||||
</div>
|
||||
<button
|
||||
v-if="(job.description?.length ?? 0) > 300"
|
||||
class="expand-btn"
|
||||
:aria-expanded="descExpanded"
|
||||
@click="descExpanded = !descExpanded"
|
||||
>
|
||||
{{ descExpanded ? 'Show less ▲' : 'Show more ▼' }}
|
||||
</button>
|
||||
|
||||
<!-- Keyword gaps -->
|
||||
<div v-if="gaps.length > 0" class="job-details__gaps">
|
||||
<span class="gaps-label">Missing keywords:</span>
|
||||
<span v-for="kw in gaps.slice(0, 6)" :key="kw" class="gap-pill">{{ kw }}</span>
|
||||
<span v-if="gaps.length > 6" class="gaps-more">+{{ gaps.length - 6 }}</span>
|
||||
</div>
|
||||
|
||||
<a v-if="job.url" :href="job.url" target="_blank" rel="noopener noreferrer" class="job-details__link">
|
||||
View listing ↗
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── Right: Cover letter ────────────────────────────────────── -->
|
||||
<main class="workspace__cl-panel">
|
||||
<h2 class="cl-heading">Cover Letter</h2>
|
||||
|
||||
<!-- State: none — no draft yet -->
|
||||
<template v-if="clState === 'none'">
|
||||
<div class="cl-empty">
|
||||
<p class="cl-empty__hint">No cover letter yet. Generate one with AI or paste your own.</p>
|
||||
<div class="cl-empty__actions">
|
||||
<button class="btn-generate" :disabled="generating" @click="generate()">
|
||||
<span aria-hidden="true">✨</span> Generate with AI
|
||||
</button>
|
||||
<button class="btn-ghost" @click="clState = 'ready'; clText = ''">
|
||||
Paste / write manually
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- State: queued / running — generating -->
|
||||
<template v-else-if="clState === 'queued' || clState === 'running'">
|
||||
<div class="cl-generating" role="status" aria-live="polite">
|
||||
<span class="spinner spinner--lg" aria-hidden="true" />
|
||||
<p class="cl-generating__label">
|
||||
{{ clState === 'queued' ? 'Queued…' : (taskStage ?? 'Generating cover letter…') }}
|
||||
</p>
|
||||
<p class="cl-generating__hint">This usually takes 20–60 seconds</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- State: failed -->
|
||||
<template v-else-if="clState === 'failed'">
|
||||
<div class="cl-error" role="alert">
|
||||
<span aria-hidden="true">⚠️</span>
|
||||
<span class="cl-error__msg">Cover letter generation failed</span>
|
||||
<span v-if="taskError" class="cl-error__detail">{{ taskError }}</span>
|
||||
<button class="btn-generate" @click="generate()">Retry</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- State: ready — editor -->
|
||||
<template v-else-if="clState === 'ready'">
|
||||
<div class="cl-editor">
|
||||
<div class="cl-editor__toolbar">
|
||||
<span class="cl-editor__wordcount" aria-live="polite">
|
||||
{{ wordCount }} words
|
||||
</span>
|
||||
<button
|
||||
class="btn-ghost btn-ghost--sm"
|
||||
:disabled="isSaved || saving"
|
||||
@click="saveCoverLetter"
|
||||
>
|
||||
{{ saving ? 'Saving…' : (isSaved ? '✓ Saved' : 'Save') }}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref="textareaEl"
|
||||
v-model="clText"
|
||||
class="cl-editor__textarea"
|
||||
aria-label="Cover letter text"
|
||||
placeholder="Your cover letter…"
|
||||
@input="isSaved = false; autoResize()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Download PDF -->
|
||||
<button class="btn-download" :disabled="!clText.trim() || downloadingPdf" @click="downloadPdf">
|
||||
<span aria-hidden="true">📄</span>
|
||||
{{ downloadingPdf ? 'Generating PDF…' : 'Download PDF' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Regenerate button (when ready, offer to redo) -->
|
||||
<button
|
||||
v-if="clState === 'ready'"
|
||||
class="btn-ghost btn-ghost--sm cl-regen"
|
||||
@click="generate()"
|
||||
>
|
||||
↺ Regenerate
|
||||
</button>
|
||||
|
||||
<!-- ── Bottom action bar ──────────────────────────────────── -->
|
||||
<div class="workspace__actions">
|
||||
<button
|
||||
class="action-btn action-btn--apply"
|
||||
:disabled="actioning"
|
||||
@click="markApplied"
|
||||
>
|
||||
<span aria-hidden="true">🚀</span>
|
||||
{{ actioning === 'apply' ? 'Marking…' : 'Mark as Applied' }}
|
||||
</button>
|
||||
<button
|
||||
class="action-btn action-btn--reject"
|
||||
:disabled="!!actioning"
|
||||
@click="rejectListing"
|
||||
>
|
||||
<span aria-hidden="true">✗</span>
|
||||
{{ actioning === 'reject' ? 'Rejecting…' : 'Reject Listing' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Toast -->
|
||||
<Transition name="toast">
|
||||
<div v-if="toast" class="toast" role="status" aria-live="polite">{{ toast }}</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
import type { Job } from '../stores/review'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const jobId = Number(route.params.id)
|
||||
|
||||
// ─── Job ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const job = ref<Job | null>(null)
|
||||
const loadingJob = ref(true)
|
||||
const descExpanded = ref(false)
|
||||
|
||||
const gaps = computed<string[]>(() => {
|
||||
if (!job.value?.keyword_gaps) return []
|
||||
try { return JSON.parse(job.value.keyword_gaps) as string[] }
|
||||
catch { return [] }
|
||||
})
|
||||
|
||||
const scoreBadgeClass = computed(() => {
|
||||
const s = job.value?.match_score ?? 0
|
||||
if (s >= 80) return 'score-badge--high'
|
||||
if (s >= 60) return 'score-badge--mid'
|
||||
return 'score-badge--low'
|
||||
})
|
||||
|
||||
// ─── Cover letter state machine ───────────────────────────────────────────────
|
||||
// none → queued → running → ready | failed
|
||||
|
||||
type ClState = 'none' | 'queued' | 'running' | 'ready' | 'failed'
|
||||
|
||||
const clState = ref<ClState>('none')
|
||||
const clText = ref('')
|
||||
const isSaved = ref(true)
|
||||
const saving = ref(false)
|
||||
const generating = ref(false)
|
||||
const taskStage = ref<string | null>(null)
|
||||
const taskError = ref<string | null>(null)
|
||||
|
||||
const wordCount = computed(() => {
|
||||
const words = clText.value.trim().split(/\s+/).filter(Boolean)
|
||||
return words.length
|
||||
})
|
||||
|
||||
// ─── Polling ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let pollTimer = 0
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = window.setInterval(pollTaskStatus, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
clearInterval(pollTimer)
|
||||
}
|
||||
|
||||
async function pollTaskStatus() {
|
||||
const { data } = await useApiFetch<{
|
||||
status: string
|
||||
stage: string | null
|
||||
message: string | null
|
||||
}>(`/api/jobs/${jobId}/cover_letter/task`)
|
||||
if (!data) return
|
||||
|
||||
taskStage.value = data.stage
|
||||
|
||||
if (data.status === 'completed') {
|
||||
stopPolling()
|
||||
// Re-fetch the job to get the new cover letter text
|
||||
await fetchJob()
|
||||
clState.value = 'ready'
|
||||
generating.value = false
|
||||
} else if (data.status === 'failed') {
|
||||
stopPolling()
|
||||
clState.value = 'failed'
|
||||
taskError.value = data.message ?? 'Unknown error'
|
||||
generating.value = false
|
||||
} else {
|
||||
clState.value = data.status === 'queued' ? 'queued' : 'running'
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function generate() {
|
||||
if (generating.value) return
|
||||
generating.value = true
|
||||
clState.value = 'queued'
|
||||
taskError.value = null
|
||||
|
||||
const { error } = await useApiFetch(`/api/jobs/${jobId}/cover_letter/generate`, { method: 'POST' })
|
||||
if (error) {
|
||||
clState.value = 'failed'
|
||||
taskError.value = error.kind === 'http' ? error.detail : 'Network error'
|
||||
generating.value = false
|
||||
return
|
||||
}
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function saveCoverLetter() {
|
||||
saving.value = true
|
||||
await useApiFetch(`/api/jobs/${jobId}/cover_letter`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: clText.value }),
|
||||
})
|
||||
saving.value = false
|
||||
isSaved.value = true
|
||||
}
|
||||
|
||||
// ─── PDF download ─────────────────────────────────────────────────────────────
|
||||
|
||||
const downloadingPdf = ref(false)
|
||||
|
||||
async function downloadPdf() {
|
||||
if (!job.value) return
|
||||
downloadingPdf.value = true
|
||||
try {
|
||||
const res = await fetch(`/api/jobs/${jobId}/cover_letter/pdf`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
const company = job.value.company.replace(/[^a-zA-Z0-9]/g, '') || 'Company'
|
||||
const dateStr = new Date().toISOString().slice(0, 10)
|
||||
a.href = url
|
||||
a.download = `CoverLetter_${company}_${dateStr}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
showToast('PDF generation failed — save first and try again')
|
||||
} finally {
|
||||
downloadingPdf.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mark applied / reject ────────────────────────────────────────────────────
|
||||
|
||||
const actioning = ref<'apply' | 'reject' | null>(null)
|
||||
|
||||
async function markApplied() {
|
||||
if (actioning.value) return
|
||||
actioning.value = 'apply'
|
||||
if (!isSaved.value) await saveCoverLetter()
|
||||
await useApiFetch(`/api/jobs/${jobId}/applied`, { method: 'POST' })
|
||||
actioning.value = null
|
||||
showToast('Marked as applied ✓')
|
||||
setTimeout(() => router.push('/apply'), 1200)
|
||||
}
|
||||
|
||||
async function rejectListing() {
|
||||
if (actioning.value) return
|
||||
actioning.value = 'reject'
|
||||
await useApiFetch(`/api/jobs/${jobId}/reject`, { method: 'POST' })
|
||||
actioning.value = null
|
||||
showToast('Listing rejected')
|
||||
setTimeout(() => router.push('/apply'), 1000)
|
||||
}
|
||||
|
||||
// ─── Toast ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const toast = ref<string | null>(null)
|
||||
let toastTimer = 0
|
||||
|
||||
function showToast(msg: string) {
|
||||
clearTimeout(toastTimer)
|
||||
toast.value = msg
|
||||
toastTimer = window.setTimeout(() => { toast.value = null }, 3500)
|
||||
}
|
||||
|
||||
// ─── Auto-resize textarea ─────────────────────────────────────────────────────
|
||||
|
||||
const textareaEl = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function autoResize() {
|
||||
const el = textareaEl.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}
|
||||
|
||||
watch(clText, () => nextTick(autoResize))
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchJob() {
|
||||
const { data } = await useApiFetch<Job>(`/api/jobs/${jobId}`)
|
||||
if (data) {
|
||||
job.value = data
|
||||
if (data.cover_letter) {
|
||||
clText.value = data.cover_letter as string
|
||||
clState.value = 'ready'
|
||||
isSaved.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchJob()
|
||||
loadingJob.value = false
|
||||
|
||||
// Check if a generation task is already in flight
|
||||
if (clState.value === 'none') {
|
||||
const { data } = await useApiFetch<{ status: string; stage: string | null }>(`/api/jobs/${jobId}/cover_letter/task`)
|
||||
if (data && (data.status === 'queued' || data.status === 'running')) {
|
||||
clState.value = data.status as ClState
|
||||
taskStage.value = data.stage
|
||||
generating.value = true
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick(autoResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
clearTimeout(toastTimer)
|
||||
})
|
||||
|
||||
// Extra type to allow cover_letter field on Job
|
||||
declare module '../stores/review' {
|
||||
interface Job { cover_letter?: string | null }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workspace {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-6) var(--space-6) var(--space-12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.workspace__back {
|
||||
color: var(--app-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
align-self: flex-start;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.workspace__back:hover { opacity: 0.7; }
|
||||
|
||||
.workspace__loading,
|
||||
.workspace__not-found {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-16);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ── Two-panel layout ────────────────────────────────────────────────── */
|
||||
|
||||
.workspace__panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.3fr;
|
||||
gap: var(--space-6);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ── Job details panel ───────────────────────────────────────────────── */
|
||||
|
||||
.workspace__job-panel {
|
||||
position: sticky;
|
||||
top: var(--space-4);
|
||||
}
|
||||
|
||||
.job-details {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.job-details__badges { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
|
||||
.job-details__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-text);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.job-details__company {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.job-details__location { font-weight: 400; }
|
||||
|
||||
.job-details__salary {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.job-details__desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.job-details__desc--clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 6;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
align-self: flex-start;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--app-primary);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.job-details__gaps { display: flex; flex-wrap: wrap; gap: var(--space-1); align-items: center; }
|
||||
.gaps-label { font-size: var(--text-xs); color: var(--color-text-muted); font-weight: 600; }
|
||||
.gap-pill {
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.gaps-more { font-size: var(--text-xs); color: var(--color-text-muted); }
|
||||
|
||||
.job-details__link {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--app-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
align-self: flex-start;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.job-details__link:hover { opacity: 0.7; }
|
||||
|
||||
/* ── Cover letter panel ──────────────────────────────────────────────── */
|
||||
|
||||
.workspace__cl-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.cl-heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.cl-empty {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cl-empty__hint { font-size: var(--text-sm); color: var(--color-text-muted); max-width: 36ch; }
|
||||
|
||||
.cl-empty__actions { display: flex; flex-direction: column; gap: var(--space-2); width: 100%; max-width: 260px; }
|
||||
|
||||
/* Generating state */
|
||||
.cl-generating {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-10);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cl-generating__label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.cl-generating__hint { font-size: var(--text-xs); color: var(--color-text-muted); }
|
||||
|
||||
/* Error state */
|
||||
.cl-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-5);
|
||||
background: rgba(192, 57, 43, 0.06);
|
||||
border: 1px solid var(--color-error);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cl-error__msg { font-weight: 700; }
|
||||
.cl-error__detail { font-size: var(--text-xs); color: var(--color-text-muted); font-weight: 400; }
|
||||
|
||||
/* Editor */
|
||||
.cl-editor {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cl-editor__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
|
||||
.cl-editor__wordcount {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.cl-editor__textarea {
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
padding: var(--space-5);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.7;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cl-editor__textarea:focus { outline: none; }
|
||||
|
||||
.cl-regen {
|
||||
align-self: flex-end;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Download button */
|
||||
.btn-download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, border-color 150ms ease;
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-download:hover:not(:disabled) { background: var(--app-primary-light); border-color: var(--app-primary); }
|
||||
.btn-download:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Generate button */
|
||||
.btn-generate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--app-accent);
|
||||
color: var(--app-accent-text);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
transition: background 150ms ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-generate:hover:not(:disabled) { background: var(--app-accent-hover); }
|
||||
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* ── Action bar ──────────────────────────────────────────────────────── */
|
||||
|
||||
.workspace__actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
min-height: 48px;
|
||||
transition: background 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.action-btn--apply {
|
||||
background: rgba(39, 174, 96, 0.10);
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
.action-btn--apply:hover:not(:disabled) { background: rgba(39, 174, 96, 0.20); }
|
||||
|
||||
.action-btn--reject {
|
||||
background: rgba(192, 57, 43, 0.08);
|
||||
border-color: var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
.action-btn--reject:hover:not(:disabled) { background: rgba(192, 57, 43, 0.16); }
|
||||
|
||||
/* ── Shared badges ───────────────────────────────────────────────────── */
|
||||
|
||||
.score-badge {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
|
||||
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
|
||||
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
|
||||
|
||||
.remote-badge {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
background: var(--app-primary-light);
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
/* ── Ghost button ────────────────────────────────────────────────────── */
|
||||
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
min-height: 36px;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-ghost:hover { background: var(--color-surface-alt); color: var(--color-text); }
|
||||
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-ghost--sm { font-size: var(--text-xs); padding: var(--space-1) var(--space-3); min-height: 28px; }
|
||||
|
||||
/* ── Spinner ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.spinner {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--app-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spinner--lg { width: 2rem; height: 2rem; border-width: 3px; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Toast ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: var(--space-6);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 300;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toast-enter-active, .toast-leave-active { transition: opacity 250ms ease, transform 250ms ease; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.workspace__panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace__job-panel {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.cl-editor__textarea { min-height: 260px; }
|
||||
|
||||
.toast {
|
||||
left: var(--space-4);
|
||||
right: var(--space-4);
|
||||
transform: none;
|
||||
bottom: calc(56px + env(safe-area-inset-bottom) + var(--space-3));
|
||||
}
|
||||
.toast-enter-from, .toast-leave-to { transform: translateY(8px); }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.workspace { padding: var(--space-4); }
|
||||
.workspace__actions { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -95,13 +95,24 @@
|
|||
listings.
|
||||
</p>
|
||||
<div class="home__actions home__actions--secondary">
|
||||
<button class="action-btn action-btn--secondary" @click="archivePendingRejected">
|
||||
📦 Archive Pending + Rejected
|
||||
<button
|
||||
v-if="(store.counts?.pending ?? 0) > 0"
|
||||
class="action-btn action-btn--secondary"
|
||||
@click="archiveByStatus(['pending'])"
|
||||
>
|
||||
📦 Archive Pending
|
||||
</button>
|
||||
<button
|
||||
v-if="(store.counts?.rejected ?? 0) > 0"
|
||||
class="action-btn action-btn--secondary"
|
||||
@click="archiveByStatus(['rejected'])"
|
||||
>
|
||||
📦 Archive Rejected
|
||||
</button>
|
||||
<button
|
||||
v-if="(store.counts?.approved ?? 0) > 0"
|
||||
class="action-btn action-btn--secondary"
|
||||
@click="archiveApproved"
|
||||
@click="archiveByStatus(['approved'])"
|
||||
>
|
||||
📦 Archive Approved (unapplied)
|
||||
</button>
|
||||
|
|
@ -258,20 +269,11 @@ function handleCsvUpload(e: Event) {
|
|||
useApiFetch('/api/jobs/upload-csv', { method: 'POST', body: form })
|
||||
}
|
||||
|
||||
async function archivePendingRejected() {
|
||||
async function archiveByStatus(statuses: string[]) {
|
||||
await useApiFetch('/api/jobs/archive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ statuses: ['pending', 'rejected'] }),
|
||||
})
|
||||
store.refresh()
|
||||
}
|
||||
|
||||
async function archiveApproved() {
|
||||
await useApiFetch('/api/jobs/archive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ statuses: ['approved'] }),
|
||||
body: JSON.stringify({ statuses }),
|
||||
})
|
||||
store.refresh()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue