fix(settings): address resume tab review issues
- add loadError ref (separated from empty-state path) - add stable id to WorkEntry, use as v-for key - move addExperience/removeExperience/addTag/removeTag to store actions - strip id from save payload - fix uploadError type handling in handleUpload - add outer try/except to upload_resume endpoint - gate syncFromProfile to non-loaded resume only - add date_of_birth input to personal info section - add loadError test
This commit is contained in:
parent
4b0db182b8
commit
837881fbe8
4 changed files with 91 additions and 53 deletions
11
dev-api.py
11
dev-api.py
|
|
@ -1074,17 +1074,22 @@ def create_blank_resume():
|
||||||
|
|
||||||
@app.post("/api/settings/resume/upload")
|
@app.post("/api/settings/resume/upload")
|
||||||
async def upload_resume(file: UploadFile):
|
async def upload_resume(file: UploadFile):
|
||||||
|
try:
|
||||||
from scripts.resume_parser import structure_resume
|
from scripts.resume_parser import structure_resume
|
||||||
import tempfile, os
|
import tempfile, os
|
||||||
suffix = Path(file.filename).suffix.lower()
|
suffix = Path(file.filename).suffix.lower()
|
||||||
|
tmp_path = None
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||||
tmp.write(await file.read())
|
tmp.write(await file.read())
|
||||||
tmp_path = tmp.name
|
tmp_path = tmp.name
|
||||||
try:
|
try:
|
||||||
result, error = structure_resume(tmp_path)
|
result, err = structure_resume(tmp_path)
|
||||||
finally:
|
finally:
|
||||||
|
if tmp_path:
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
if error:
|
if err:
|
||||||
return {"ok": False, "error": error, "data": result}
|
return {"ok": False, "error": err, "data": result}
|
||||||
result["exists"] = True
|
result["exists"] = True
|
||||||
return {"ok": True, "data": result}
|
return {"ok": True, "data": result}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,12 @@ describe('useResumeStore', () => {
|
||||||
await store.load()
|
await store.load()
|
||||||
expect(store.hasResume).toBe(false)
|
expect(store.hasResume).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('load() sets loadError on API error', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'Network error' } })
|
||||||
|
const store = useResumeStore()
|
||||||
|
await store.load()
|
||||||
|
expect(store.loadError).toBeTruthy()
|
||||||
|
expect(store.hasResume).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
|
||||||
import { useApiFetch } from '../../composables/useApi'
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
export interface WorkEntry {
|
export interface WorkEntry {
|
||||||
|
id: string
|
||||||
title: string; company: string; period: string; location: string
|
title: string; company: string; period: string; location: string
|
||||||
industry: string; responsibilities: string; skills: string[]
|
industry: string; responsibilities: string; skills: string[]
|
||||||
}
|
}
|
||||||
|
|
@ -12,6 +13,7 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const saveError = ref<string | null>(null)
|
const saveError = ref<string | null>(null)
|
||||||
|
const loadError = ref<string | null>(null)
|
||||||
|
|
||||||
// Identity (synced from profile store)
|
// Identity (synced from profile store)
|
||||||
const name = ref(''); const email = ref(''); const phone = ref(''); const linkedin_url = ref('')
|
const name = ref(''); const email = ref(''); const phone = ref(''); const linkedin_url = ref('')
|
||||||
|
|
@ -37,16 +39,21 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
loadError.value = null
|
||||||
const { data, error } = await useApiFetch<Record<string, unknown>>('/api/settings/resume')
|
const { data, error } = await useApiFetch<Record<string, unknown>>('/api/settings/resume')
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (error || !data || !data.exists) { hasResume.value = false; return }
|
if (error) {
|
||||||
|
loadError.value = error.kind === 'network' ? error.message : (error.detail || 'Failed to load resume')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!data || !data.exists) { hasResume.value = false; return }
|
||||||
hasResume.value = true
|
hasResume.value = true
|
||||||
name.value = String(data.name ?? ''); email.value = String(data.email ?? '')
|
name.value = String(data.name ?? ''); email.value = String(data.email ?? '')
|
||||||
phone.value = String(data.phone ?? ''); linkedin_url.value = String(data.linkedin_url ?? '')
|
phone.value = String(data.phone ?? ''); linkedin_url.value = String(data.linkedin_url ?? '')
|
||||||
surname.value = String(data.surname ?? ''); address.value = String(data.address ?? '')
|
surname.value = String(data.surname ?? ''); address.value = String(data.address ?? '')
|
||||||
city.value = String(data.city ?? ''); zip_code.value = String(data.zip_code ?? '')
|
city.value = String(data.city ?? ''); zip_code.value = String(data.zip_code ?? '')
|
||||||
date_of_birth.value = String(data.date_of_birth ?? '')
|
date_of_birth.value = String(data.date_of_birth ?? '')
|
||||||
experience.value = (data.experience as WorkEntry[]) ?? []
|
experience.value = (data.experience as Omit<WorkEntry, 'id'>[]).map(e => ({ ...e, id: crypto.randomUUID() })) ?? []
|
||||||
salary_min.value = Number(data.salary_min ?? 0); salary_max.value = Number(data.salary_max ?? 0)
|
salary_min.value = Number(data.salary_min ?? 0); salary_max.value = Number(data.salary_max ?? 0)
|
||||||
notice_period.value = String(data.notice_period ?? '')
|
notice_period.value = String(data.notice_period ?? '')
|
||||||
remote.value = Boolean(data.remote); relocation.value = Boolean(data.relocation)
|
remote.value = Boolean(data.remote); relocation.value = Boolean(data.relocation)
|
||||||
|
|
@ -64,7 +71,8 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
||||||
const body = {
|
const body = {
|
||||||
name: name.value, email: email.value, phone: phone.value, linkedin_url: linkedin_url.value,
|
name: name.value, email: email.value, phone: phone.value, linkedin_url: linkedin_url.value,
|
||||||
surname: surname.value, address: address.value, city: city.value, zip_code: zip_code.value,
|
surname: surname.value, address: address.value, city: city.value, zip_code: zip_code.value,
|
||||||
date_of_birth: date_of_birth.value, experience: experience.value,
|
date_of_birth: date_of_birth.value,
|
||||||
|
experience: experience.value.map(({ id: _id, ...e }) => e),
|
||||||
salary_min: salary_min.value, salary_max: salary_max.value, notice_period: notice_period.value,
|
salary_min: salary_min.value, salary_max: salary_max.value, notice_period: notice_period.value,
|
||||||
remote: remote.value, relocation: relocation.value,
|
remote: remote.value, relocation: relocation.value,
|
||||||
assessment: assessment.value, background_check: background_check.value,
|
assessment: assessment.value, background_check: background_check.value,
|
||||||
|
|
@ -84,12 +92,34 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
||||||
if (!error) { hasResume.value = true; await load() }
|
if (!error) { hasResume.value = true; await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addExperience() {
|
||||||
|
experience.value.push({ id: crypto.randomUUID(), title: '', company: '', period: '', location: '', industry: '', responsibilities: '', skills: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeExperience(idx: number) {
|
||||||
|
experience.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag(field: 'skills' | 'domains' | 'keywords', value: string) {
|
||||||
|
const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed || arr.includes(trimmed)) return
|
||||||
|
arr.push(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(field: 'skills' | 'domains' | 'keywords', value: string) {
|
||||||
|
const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
|
||||||
|
const idx = arr.indexOf(value)
|
||||||
|
if (idx !== -1) arr.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasResume, loading, saving, saveError,
|
hasResume, loading, saving, saveError, loadError,
|
||||||
name, email, phone, linkedin_url, surname, address, city, zip_code, date_of_birth,
|
name, email, phone, linkedin_url, surname, address, city, zip_code, date_of_birth,
|
||||||
experience, salary_min, salary_max, notice_period, remote, relocation, assessment, background_check,
|
experience, salary_min, salary_max, notice_period, remote, relocation, assessment, background_check,
|
||||||
gender, pronouns, ethnicity, veteran_status, disability,
|
gender, pronouns, ethnicity, veteran_status, disability,
|
||||||
skills, domains, keywords,
|
skills, domains, keywords,
|
||||||
syncFromProfile, load, save, createBlank,
|
syncFromProfile, load, save, createBlank,
|
||||||
|
addExperience, removeExperience, addTag, removeTag,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
<div class="resume-profile">
|
<div class="resume-profile">
|
||||||
<h2>Resume Profile</h2>
|
<h2>Resume Profile</h2>
|
||||||
|
|
||||||
|
<!-- Load error banner -->
|
||||||
|
<div v-if="loadError" class="error-banner">
|
||||||
|
Failed to load resume: {{ loadError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div v-if="!store.hasResume && !store.loading" class="empty-state">
|
<div v-if="!store.hasResume && !store.loading" class="empty-state">
|
||||||
<p>No resume found. Choose how to get started:</p>
|
<p>No resume found. Choose how to get started:</p>
|
||||||
|
|
@ -65,12 +70,16 @@
|
||||||
<label>ZIP Code</label>
|
<label>ZIP Code</label>
|
||||||
<input v-model="store.zip_code" />
|
<input v-model="store.zip_code" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Date of Birth</label>
|
||||||
|
<input v-model="store.date_of_birth" type="date" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Work Experience -->
|
<!-- Work Experience -->
|
||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<h3>Work Experience</h3>
|
<h3>Work Experience</h3>
|
||||||
<div v-for="(entry, idx) in store.experience" :key="idx" class="experience-card">
|
<div v-for="(entry, idx) in store.experience" :key="entry.id" class="experience-card">
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label>Job Title</label>
|
<label>Job Title</label>
|
||||||
<input v-model="entry.title" />
|
<input v-model="entry.title" />
|
||||||
|
|
@ -95,9 +104,9 @@
|
||||||
<label>Responsibilities</label>
|
<label>Responsibilities</label>
|
||||||
<textarea v-model="entry.responsibilities" rows="4" />
|
<textarea v-model="entry.responsibilities" rows="4" />
|
||||||
</div>
|
</div>
|
||||||
<button class="remove-btn" @click="removeExperience(idx)">Remove</button>
|
<button class="remove-btn" @click="store.removeExperience(idx)">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
<button @click="addExperience">+ Add Position</button>
|
<button @click="store.addExperience()">+ Add Position</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Preferences -->
|
<!-- Preferences -->
|
||||||
|
|
@ -169,28 +178,28 @@
|
||||||
<label>Skills</label>
|
<label>Skills</label>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span v-for="skill in store.skills" :key="skill" class="tag">
|
<span v-for="skill in store.skills" :key="skill" class="tag">
|
||||||
{{ skill }} <button @click="removeTag('skills', skill)">×</button>
|
{{ skill }} <button @click="store.removeTag('skills', skill)">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input v-model="skillInput" @keydown.enter.prevent="addTag('skills', skillInput); skillInput = ''" placeholder="Add skill, press Enter" />
|
<input v-model="skillInput" @keydown.enter.prevent="store.addTag('skills', skillInput); skillInput = ''" placeholder="Add skill, press Enter" />
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-section">
|
<div class="tag-section">
|
||||||
<label>Domains</label>
|
<label>Domains</label>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span v-for="domain in store.domains" :key="domain" class="tag">
|
<span v-for="domain in store.domains" :key="domain" class="tag">
|
||||||
{{ domain }} <button @click="removeTag('domains', domain)">×</button>
|
{{ domain }} <button @click="store.removeTag('domains', domain)">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input v-model="domainInput" @keydown.enter.prevent="addTag('domains', domainInput); domainInput = ''" placeholder="Add domain, press Enter" />
|
<input v-model="domainInput" @keydown.enter.prevent="store.addTag('domains', domainInput); domainInput = ''" placeholder="Add domain, press Enter" />
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-section">
|
<div class="tag-section">
|
||||||
<label>Keywords</label>
|
<label>Keywords</label>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span v-for="kw in store.keywords" :key="kw" class="tag">
|
<span v-for="kw in store.keywords" :key="kw" class="tag">
|
||||||
{{ kw }} <button @click="removeTag('keywords', kw)">×</button>
|
{{ kw }} <button @click="store.removeTag('keywords', kw)">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input v-model="kwInput" @keydown.enter.prevent="addTag('keywords', kwInput); kwInput = ''" placeholder="Add keyword, press Enter" />
|
<input v-model="kwInput" @keydown.enter.prevent="store.addTag('keywords', kwInput); kwInput = ''" placeholder="Add keyword, press Enter" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -209,12 +218,14 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import { useResumeStore } from '../../stores/settings/resume'
|
import { useResumeStore } from '../../stores/settings/resume'
|
||||||
import { useProfileStore } from '../../stores/settings/profile'
|
import { useProfileStore } from '../../stores/settings/profile'
|
||||||
import { useApiFetch } from '../../composables/useApi'
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
const store = useResumeStore()
|
const store = useResumeStore()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
|
const { loadError } = storeToRefs(store)
|
||||||
const showSelfId = ref(false)
|
const showSelfId = ref(false)
|
||||||
const skillInput = ref('')
|
const skillInput = ref('')
|
||||||
const domainInput = ref('')
|
const domainInput = ref('')
|
||||||
|
|
@ -224,34 +235,17 @@ const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.load()
|
await store.load()
|
||||||
|
// Only prime identity from profile on a fresh/empty resume
|
||||||
|
if (!store.hasResume) {
|
||||||
store.syncFromProfile({
|
store.syncFromProfile({
|
||||||
name: profileStore.name,
|
name: profileStore.name,
|
||||||
email: profileStore.email,
|
email: profileStore.email,
|
||||||
phone: profileStore.phone,
|
phone: profileStore.phone,
|
||||||
linkedin_url: profileStore.linkedin_url,
|
linkedin_url: profileStore.linkedin_url,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function addExperience() {
|
|
||||||
store.experience.push({ title: '', company: '', period: '', location: '', industry: '', responsibilities: '', skills: [] })
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeExperience(idx: number) {
|
|
||||||
store.experience.splice(idx, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTag(field: 'skills' | 'domains' | 'keywords', value: string) {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed || store[field].includes(trimmed)) return
|
|
||||||
store[field].push(trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTag(field: 'skills' | 'domains' | 'keywords', value: string) {
|
|
||||||
const arr = store[field] as string[]
|
|
||||||
const idx = arr.indexOf(value)
|
|
||||||
if (idx !== -1) arr.splice(idx, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpload(event: Event) {
|
async function handleUpload(event: Event) {
|
||||||
const file = (event.target as HTMLInputElement).files?.[0]
|
const file = (event.target as HTMLInputElement).files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
@ -263,7 +257,7 @@ async function handleUpload(event: Event) {
|
||||||
{ method: 'POST', body: formData }
|
{ method: 'POST', body: formData }
|
||||||
)
|
)
|
||||||
if (error || !data?.ok) {
|
if (error || !data?.ok) {
|
||||||
uploadError.value = data?.error ?? error ?? 'Upload failed'
|
uploadError.value = data?.error ?? (typeof error === 'string' ? error : (error?.kind === 'network' ? error.message : error?.detail ?? 'Upload failed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (data.data) {
|
if (data.data) {
|
||||||
|
|
@ -309,6 +303,7 @@ h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); col
|
||||||
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
|
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
|
||||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.error { color: #ef4444; font-size: 0.82rem; }
|
.error { color: #ef4444; font-size: 0.82rem; }
|
||||||
|
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; font-size: 0.85rem; padding: 10px 14px; margin-bottom: var(--space-4, 24px); }
|
||||||
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
|
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
|
||||||
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
|
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
|
||||||
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
|
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue