jobhunt-platform/apps/web/src/views/CvEditor.vue
hermes bc81cb2398 T3: apps/web frontend shell (Vue 3 + Vite + TypeScript + Pinia + vue-router + Tailwind)
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)
2026-07-30 18:00:55 +00:00

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>