feat: references tracker and recommendation letter system (#96)
- references_ + job_references tables with CREATE + migration - Full CRUD: GET/POST /api/references, PATCH/DELETE /api/references/:id - Link/unlink to jobs: POST/DELETE /api/references/:id/link-job/:job_id - GET /api/references/for-job/:job_id — linked refs with prep/letter drafts - POST /api/references/:id/prep-email — LLM drafts heads-up email to send reference before interview; persisted to job_references.prep_email - POST /api/references/:id/rec-letter — LLM drafts recommendation letter reference can edit and send on their letterhead (Paid/BYOK tier) - ReferencesView.vue: add/edit/delete form, tag system (technical/managerial/ character/academic), inline confirm-before-delete - Route /references + IdentificationIcon nav link
This commit is contained in:
parent
0e4fce44c4
commit
03b9e52301
5 changed files with 724 additions and 2 deletions
219
dev-api.py
219
dev-api.py
|
|
@ -1571,6 +1571,225 @@ def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None,
|
|||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
PIPELINE_STATUSES = {
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
("cover_letter", "TEXT"),
|
||||
("applied_at", "TEXT"),
|
||||
|
|
@ -178,6 +204,9 @@ def _migrate_db(db_path: Path) -> None:
|
|||
conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
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.close()
|
||||
|
||||
|
|
@ -191,6 +220,8 @@ def init_db(db_path: Path = DEFAULT_DB) -> None:
|
|||
conn.execute(CREATE_BACKGROUND_TASKS)
|
||||
conn.execute(CREATE_SURVEY_RESPONSES)
|
||||
conn.execute(CREATE_DIGEST_QUEUE)
|
||||
conn.execute(CREATE_REFERENCES)
|
||||
conn.execute(CREATE_JOB_REFERENCES)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
_migrate_db(db_path)
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ import {
|
|||
Cog6ToothIcon,
|
||||
DocumentTextIcon,
|
||||
UsersIcon,
|
||||
IdentificationIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
import { useDigestStore } from '../stores/digest'
|
||||
|
|
@ -156,7 +157,8 @@ const navLinks = computed(() => [
|
|||
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
|
||||
{ to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' },
|
||||
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
|
||||
{ to: '/contacts', icon: UsersIcon, label: 'Contacts' },
|
||||
{ to: '/contacts', icon: UsersIcon, label: 'Contacts' },
|
||||
{ to: '/references', icon: IdentificationIcon, label: 'References' },
|
||||
{ to: '/digest', icon: NewspaperIcon, label: 'Digest',
|
||||
badge: digestStore.entries.length || undefined },
|
||||
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ export const router = createRouter({
|
|||
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
|
||||
{ path: '/resumes', component: () => import('../views/ResumesView.vue') },
|
||||
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
|
||||
{ path: '/contacts', component: () => import('../views/ContactsView.vue') },
|
||||
{ path: '/contacts', component: () => import('../views/ContactsView.vue') },
|
||||
{ path: '/references', component: () => import('../views/ReferencesView.vue') },
|
||||
{ path: '/digest', component: () => import('../views/DigestView.vue') },
|
||||
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
|
||||
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
|
||||
|
|
|
|||
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