Implements the four views per the task card:
1. CV Editor (/cv):
- Profile form (name/email/phone/location/headline/summary) with save
- Section list editor: add/edit/delete sections with kind select,
title/org/dates, bullet list editor with per-bullet AI-assist
button calling POST /profile/sections/{id}/ai-assist, suggestions
shown with accept/dismiss
- Render CV button -> POST /profile/render-cv, shows URL link
2. Research (/research):
- Table of postings (GET /postings) with company/title/location/
source/fetched_at
- Add by URL input -> POST /postings
- Score button per row -> shows score result
3. Applications (/applications):
- Kanban board grouped by state (10 columns per data-model states)
- Cards show company/title/score
- HTML5 drag-and-drop between columns -> POST /applications/{id}/transition
- Optimistic update, revert on 409 with toast showing reason
- Click card to navigate to detail view
4. Application detail (/applications/:id):
- Posting info, state, score
- Artifacts list
- Cover-letter editor (textarea) -> save calls POST /applications/{id}/artifacts/
cover-letter, critique rendered as cards with severity color coding
- Approval widget: select artifact + action -> request approval ->
I confirm button -> Send button (disabled until confirmed; shows
409 errors as toasts)
Tech stack:
- Vue 3 + Vite + TypeScript (strict, noUnusedLocals/Parameters)
- Pinia for state (toast store)
- vue-router with lazy-loaded views
- Tailwind CSS configured locally (no CDN), PostCSS + autoprefixer
- API base from VITE_API_BASE defaulting to http://localhost:8000/api
- Typed API client module (src/api/index.ts) matching the contract
- Domain types (src/types/index.ts) from data-model.md
Tests (vitest, all passing):
- router.test.ts: router renders 3 tab links (CV, Research, Applications)
- Applications.test.ts: kanban groups cards by state from fixture
- ApplicationDetail.test.ts: Send disabled until confirmed; 409 error toast
Build: npm run build passes (vue-tsc --noEmit + vite build)
Tests: npm run test passes (4 tests, 3 files)
338 lines
No EOL
11 KiB
Vue
338 lines
No EOL
11 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, ref } from 'vue'
|
|
import { useToastStore } from '@/stores/toast'
|
|
import * as api from '@/api'
|
|
import type { Profile, CvSection, CvSectionKind } from '@/types'
|
|
|
|
const toast = useToastStore()
|
|
|
|
const profile = ref<Profile | null>(null)
|
|
const sections = ref<CvSection[]>([])
|
|
const loading = ref(true)
|
|
const rendering = ref(false)
|
|
const renderUrl = ref<string | null>(null)
|
|
|
|
const sectionKinds: CvSectionKind[] = ['experience', 'education', 'skills', 'projects', 'other']
|
|
|
|
// Edit state for profile form
|
|
const form = ref({
|
|
full_name: '',
|
|
email: '',
|
|
phone: '',
|
|
location: '',
|
|
headline: '',
|
|
summary: ''
|
|
})
|
|
|
|
// New section draft
|
|
const newSection = ref({
|
|
kind: 'experience' as CvSectionKind,
|
|
title: '',
|
|
org: '',
|
|
location: '',
|
|
start_date: '',
|
|
end_date: '',
|
|
bullets: [] as string[],
|
|
tags: [] as string[]
|
|
})
|
|
|
|
const newBullet = ref('')
|
|
const newTag = ref('')
|
|
|
|
// AI assist state per section
|
|
const assistSectionId = ref<string | null>(null)
|
|
const assistInstruction = ref('')
|
|
const assistSuggestions = ref<string[]>([])
|
|
const assistLoading = ref(false)
|
|
|
|
async function loadProfile() {
|
|
try {
|
|
profile.value = await api.getProfile()
|
|
Object.assign(form.value, {
|
|
full_name: profile.value.full_name,
|
|
email: profile.value.email,
|
|
phone: profile.value.phone ?? '',
|
|
location: profile.value.location ?? '',
|
|
headline: profile.value.headline ?? '',
|
|
summary: profile.value.summary ?? ''
|
|
})
|
|
} catch {
|
|
toast.push('Failed to load profile', 'error')
|
|
}
|
|
}
|
|
|
|
async function loadSections() {
|
|
try {
|
|
sections.value = await api.getSections()
|
|
} catch {
|
|
toast.push('Failed to load sections', 'error')
|
|
}
|
|
}
|
|
|
|
async function saveProfile() {
|
|
try {
|
|
profile.value = await api.updateProfile(form.value)
|
|
toast.push('Profile saved', 'success')
|
|
} catch {
|
|
toast.push('Failed to save profile', 'error')
|
|
}
|
|
}
|
|
|
|
async function addSection() {
|
|
try {
|
|
const s = await api.createSection({
|
|
...newSection.value,
|
|
sort_order: sections.value.length
|
|
})
|
|
sections.value.push(s)
|
|
newSection.value = {
|
|
kind: 'experience',
|
|
title: '',
|
|
org: '',
|
|
location: '',
|
|
start_date: '',
|
|
end_date: '',
|
|
bullets: [],
|
|
tags: []
|
|
}
|
|
toast.push('Section added', 'success')
|
|
} catch {
|
|
toast.push('Failed to add section', 'error')
|
|
}
|
|
}
|
|
|
|
async function removeSection(id: string) {
|
|
try {
|
|
await api.deleteSection(id)
|
|
sections.value = sections.value.filter((s) => s.id !== id)
|
|
toast.push('Section deleted', 'success')
|
|
} catch {
|
|
toast.push('Failed to delete section', 'error')
|
|
}
|
|
}
|
|
|
|
async function saveSection(section: CvSection) {
|
|
try {
|
|
await api.updateSection(section.id, section)
|
|
toast.push('Section saved', 'success')
|
|
} catch {
|
|
toast.push('Failed to save section', 'error')
|
|
}
|
|
}
|
|
|
|
function addBullet() {
|
|
if (newBullet.value.trim()) {
|
|
newSection.value.bullets.push(newBullet.value.trim())
|
|
newBullet.value = ''
|
|
}
|
|
}
|
|
|
|
function addTag() {
|
|
if (newTag.value.trim()) {
|
|
newSection.value.tags.push(newTag.value.trim())
|
|
newTag.value = ''
|
|
}
|
|
}
|
|
|
|
function removeBullet(idx: number) {
|
|
newSection.value.bullets.splice(idx, 1)
|
|
}
|
|
|
|
async function callAiAssist(sectionId: string) {
|
|
assistLoading.value = true
|
|
assistSuggestions.value = []
|
|
try {
|
|
const res = await api.aiAssist(sectionId, assistInstruction.value || 'Improve this bullet')
|
|
assistSuggestions.value = res.suggestions
|
|
assistSectionId.value = sectionId
|
|
} catch {
|
|
toast.push('AI assist failed', 'error')
|
|
} finally {
|
|
assistLoading.value = false
|
|
}
|
|
}
|
|
|
|
function acceptSuggestion(text: string) {
|
|
const section = sections.value.find((s) => s.id === assistSectionId.value)
|
|
if (section) {
|
|
section.bullets.push(text)
|
|
assistSuggestions.value = assistSuggestions.value.filter((s) => s !== text)
|
|
}
|
|
}
|
|
|
|
function dismissSuggestion(text: string) {
|
|
assistSuggestions.value = assistSuggestions.value.filter((s) => s !== text)
|
|
}
|
|
|
|
async function doRenderCv() {
|
|
rendering.value = true
|
|
renderUrl.value = null
|
|
try {
|
|
const res = await api.renderCv()
|
|
renderUrl.value = res.url
|
|
toast.push('CV rendered', 'success')
|
|
} catch {
|
|
toast.push('Render failed', 'error')
|
|
} finally {
|
|
rendering.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([loadProfile(), loadSections()])
|
|
loading.value = false
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-6">
|
|
<h1 class="text-2xl font-bold">CV Editor</h1>
|
|
|
|
<div v-if="loading" class="text-gray-500">Loading...</div>
|
|
|
|
<!-- Profile form -->
|
|
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
<h2 class="font-semibold text-lg">Profile</h2>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">Full name</span>
|
|
<input v-model="form.full_name" class="w-full border rounded px-2 py-1 mt-1" />
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">Email</span>
|
|
<input v-model="form.email" class="w-full border rounded px-2 py-1 mt-1" />
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">Phone</span>
|
|
<input v-model="form.phone" class="w-full border rounded px-2 py-1 mt-1" />
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">Location</span>
|
|
<input v-model="form.location" class="w-full border rounded px-2 py-1 mt-1" />
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">Headline</span>
|
|
<input v-model="form.headline" class="w-full border rounded px-2 py-1 mt-1" />
|
|
</label>
|
|
</div>
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">Summary</span>
|
|
<textarea v-model="form.summary" rows="3" class="w-full border rounded px-2 py-1 mt-1"></textarea>
|
|
</label>
|
|
<button @click="saveProfile" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">Save Profile</button>
|
|
</section>
|
|
|
|
<!-- Sections list -->
|
|
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
<h2 class="font-semibold text-lg">Sections</h2>
|
|
<div v-for="section in sections" :key="section.id" class="border-b border-gray-100 pb-3 mb-3">
|
|
<div class="flex items-center justify-between">
|
|
<span class="font-medium">{{ section.title }} ({{ section.kind }})</span>
|
|
<div class="flex gap-2">
|
|
<button @click="saveSection(section)" class="text-sm text-indigo-600 hover:underline">Save</button>
|
|
<button @click="removeSection(section.id)" class="text-sm text-red-600 hover:underline">Delete</button>
|
|
</div>
|
|
</div>
|
|
<div class="text-sm text-gray-500">{{ section.org }} {{ section.location }}</div>
|
|
<ul class="list-disc ml-6 text-sm mt-1">
|
|
<li v-for="(b, i) in section.bullets" :key="i">{{ b }}</li>
|
|
</ul>
|
|
<div class="mt-2 flex gap-2 items-center">
|
|
<input
|
|
v-model="assistInstruction"
|
|
placeholder="AI assist instruction"
|
|
class="border rounded px-2 py-1 text-sm flex-1"
|
|
/>
|
|
<button
|
|
@click="callAiAssist(section.id)"
|
|
:disabled="assistLoading"
|
|
class="text-sm bg-purple-600 text-white px-3 py-1 rounded"
|
|
>
|
|
AI Assist
|
|
</button>
|
|
</div>
|
|
<div v-if="assistSectionId === section.id && assistSuggestions.length" class="mt-2 space-y-1">
|
|
<div
|
|
v-for="s in assistSuggestions"
|
|
:key="s"
|
|
class="flex items-center justify-between bg-purple-50 rounded px-2 py-1 text-sm"
|
|
>
|
|
<span>{{ s }}</span>
|
|
<span class="flex gap-2">
|
|
<button @click="acceptSuggestion(s)" class="text-green-600">Accept</button>
|
|
<button @click="dismissSuggestion(s)" class="text-gray-500">Dismiss</button>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Add section -->
|
|
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
<h2 class="font-semibold text-lg">Add Section</h2>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">Kind</span>
|
|
<select v-model="newSection.kind" class="w-full border rounded px-2 py-1 mt-1">
|
|
<option v-for="k in sectionKinds" :key="k" :value="k">{{ k }}</option>
|
|
</select>
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">Title</span>
|
|
<input v-model="newSection.title" class="w-full border rounded px-2 py-1 mt-1" />
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">Organization</span>
|
|
<input v-model="newSection.org" class="w-full border rounded px-2 py-1 mt-1" />
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">Location</span>
|
|
<input v-model="newSection.location" class="w-full border rounded px-2 py-1 mt-1" />
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">Start date</span>
|
|
<input v-model="newSection.start_date" type="date" class="w-full border rounded px-2 py-1 mt-1" />
|
|
</label>
|
|
<label class="block">
|
|
<span class="text-sm text-gray-600">End date</span>
|
|
<input v-model="newSection.end_date" type="date" class="w-full border rounded px-2 py-1 mt-1" />
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm text-gray-600">Bullets</span>
|
|
<ul class="ml-6 text-sm list-disc">
|
|
<li v-for="(b, i) in newSection.bullets" :key="i" class="flex items-center gap-2">
|
|
<span class="flex-1">{{ b }}</span>
|
|
<button @click="removeBullet(i)" class="text-red-600 text-xs">x</button>
|
|
</li>
|
|
</ul>
|
|
<div class="flex gap-2 mt-1">
|
|
<input v-model="newBullet" class="border rounded px-2 py-1 text-sm flex-1" @keyup.enter="addBullet" />
|
|
<button @click="addBullet" class="text-sm bg-gray-200 px-2 rounded">+</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm text-gray-600">Tags</span>
|
|
<div class="flex flex-wrap gap-1 mt-1">
|
|
<span v-for="t in newSection.tags" :key="t" class="text-xs bg-gray-200 rounded px-2 py-0.5">{{ t }}</span>
|
|
</div>
|
|
<div class="flex gap-2 mt-1">
|
|
<input v-model="newTag" class="border rounded px-2 py-1 text-sm flex-1" @keyup.enter="addTag" />
|
|
<button @click="addTag" class="text-sm bg-gray-200 px-2 rounded">+</button>
|
|
</div>
|
|
</div>
|
|
<button @click="addSection" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">Add Section</button>
|
|
</section>
|
|
|
|
<!-- Render CV -->
|
|
<section v-if="!loading" class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
<h2 class="font-semibold text-lg">Render CV</h2>
|
|
<button @click="doRenderCv" :disabled="rendering" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">
|
|
{{ rendering ? 'Rendering...' : 'Render PDF' }}
|
|
</button>
|
|
<a v-if="renderUrl" :href="renderUrl" target="_blank" class="text-indigo-600 underline text-sm block">
|
|
Download rendered CV
|
|
</a>
|
|
</section>
|
|
</div>
|
|
</template> |