jobhunt-platform/apps/web/src/views/ApplicationDetail.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

251 lines
No EOL
8.6 KiB
Vue

<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useToastStore } from '@/stores/toast'
import * as api from '@/api'
import { HttpError } from '@/api'
import type {
Application,
Artifact,
Approval,
ApprovalAction,
CoverLetterResponse,
CritiqueComment
} from '@/types'
const props = defineProps<{ id: string }>()
const toast = useToastStore()
const application = ref<Application | null>(null)
const artifacts = ref<Artifact[]>([])
const loading = ref(true)
// Cover letter editor
const letterText = ref('')
const critique = ref<CritiqueComment[]>([])
const savingLetter = ref(false)
// Approval widget
const selectedArtifactId = ref('')
const selectedAction = ref<ApprovalAction>('send_email')
const approval = ref<Approval | null>(null)
const requestingApproval = ref(false)
const confirming = ref(false)
const sending = ref(false)
const isConfirmed = computed(() => approval.value?.confirmed_by_user ?? false)
const canSend = computed(() => isConfirmed.value && !sending.value)
const actions: ApprovalAction[] = ['send_email', 'submit_application']
const severityClass: Record<string, string> = {
high: 'bg-red-50 border-red-200',
medium: 'bg-yellow-50 border-yellow-200',
low: 'bg-blue-50 border-blue-200'
}
async function loadData() {
try {
const apps = await api.getApplications()
application.value = apps.find((a) => a.id === props.id) ?? null
artifacts.value = await api.getArtifacts(props.id)
} catch {
toast.push('Failed to load application', 'error')
} finally {
loading.value = false
}
}
async function saveCoverLetter() {
savingLetter.value = true
critique.value = []
try {
const res: CoverLetterResponse = await api.createCoverLetter(props.id, letterText.value)
artifacts.value = [res.artifact, ...artifacts.value.filter((a) => a.id !== res.artifact.id)]
critique.value = res.comments
toast.push('Cover letter saved with critique', 'success')
} catch {
toast.push('Failed to save cover letter', 'error')
} finally {
savingLetter.value = false
}
}
async function requestApproval() {
if (!selectedArtifactId.value) {
toast.push('Select an artifact first', 'error')
return
}
requestingApproval.value = true
try {
approval.value = await api.createApproval(props.id, selectedAction.value, selectedArtifactId.value)
toast.push('Approval requested', 'success')
} catch (err) {
let msg = 'Failed to request approval'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
requestingApproval.value = false
}
}
async function confirmApprovalAction() {
if (!approval.value) return
confirming.value = true
try {
approval.value = await api.confirmApproval(approval.value.id)
toast.push('Approval confirmed', 'success')
} catch (err) {
let msg = 'Confirm failed'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
confirming.value = false
}
}
async function sendOutbox() {
if (!approval.value || !canSend.value) return
sending.value = true
try {
await api.outboxSend(approval.value.id, { to: application.value?.posting?.company ?? '' })
toast.push('Sent successfully', 'success')
} catch (err) {
let msg = 'Send failed'
if (err instanceof HttpError) {
const body = err.body as { error?: { message?: string } } | null
msg = body?.error?.message ?? msg
}
toast.push(msg, 'error')
} finally {
sending.value = false
}
}
onMounted(loadData)
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold">Application Detail</h1>
<div v-if="loading" class="text-gray-500">Loading...</div>
<template v-if="!loading && application">
<!-- Posting info -->
<section class="bg-white rounded-lg border border-gray-200 p-4">
<div class="font-semibold text-lg">{{ application.posting?.company ?? 'Unknown' }}</div>
<div class="text-gray-600">{{ application.posting?.title ?? 'No title' }}</div>
<div class="text-sm text-gray-500 mt-1">
State: <span class="capitalize font-medium">{{ application.state }}</span>
<span v-if="application.score != null" class="ml-3">Score: {{ application.score }}</span>
</div>
</section>
<!-- Artifacts list -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-2">
<h2 class="font-semibold">Artifacts</h2>
<ul v-if="artifacts.length" class="text-sm space-y-1">
<li v-for="a in artifacts" :key="a.id" class="flex justify-between border-b border-gray-100 py-1">
<span>{{ a.filename }} ({{ a.kind }})</span>
<span class="text-gray-400 text-xs">{{ a.content_hash.slice(0, 12) }}</span>
</li>
</ul>
<p v-else class="text-sm text-gray-400">No artifacts yet.</p>
</section>
<!-- Cover letter editor + critique -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold">Cover Letter</h2>
<textarea
v-model="letterText"
rows="8"
class="w-full border rounded px-2 py-1 text-sm"
placeholder="Write your cover letter..."
></textarea>
<button
@click="saveCoverLetter"
:disabled="savingLetter"
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm"
>
{{ savingLetter ? 'Saving...' : 'Save & Get Critique' }}
</button>
<div v-if="critique.length" class="space-y-2 mt-3">
<h3 class="font-medium text-sm">Critique</h3>
<div
v-for="(c, i) in critique"
:key="i"
class="border rounded p-2 text-sm"
:class="severityClass[c.severity] ?? 'bg-gray-50 border-gray-200'"
>
<div class="font-medium capitalize">{{ c.severity }}</div>
<div class="text-gray-700 italic">"{{ c.quote }}"</div>
<div class="text-gray-600 mt-1">{{ c.suggestion }}</div>
</div>
</div>
</section>
<!-- Approval widget -->
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
<h2 class="font-semibold">Approval & Send</h2>
<div class="flex gap-3 items-end">
<label class="block">
<span class="text-sm text-gray-600">Artifact</span>
<select v-model="selectedArtifactId" class="border rounded px-2 py-1 mt-1 text-sm">
<option value="" disabled>Select...</option>
<option v-for="a in artifacts" :key="a.id" :value="a.id">{{ a.filename }} ({{ a.kind }})</option>
</select>
</label>
<label class="block">
<span class="text-sm text-gray-600">Action</span>
<select v-model="selectedAction" class="border rounded px-2 py-1 mt-1 text-sm">
<option v-for="a in actions" :key="a" :value="a">{{ a }}</option>
</select>
</label>
<button
@click="requestApproval"
:disabled="requestingApproval || !selectedArtifactId"
class="bg-gray-700 text-white px-4 py-2 rounded text-sm"
>
{{ requestingApproval ? 'Requesting...' : 'Request Approval' }}
</button>
</div>
<div v-if="approval" class="space-y-2 border-t border-gray-100 pt-3">
<div class="text-sm">
Approval ID: <code>{{ approval.id.slice(0, 8) }}</code>
</div>
<div class="text-sm">
Confirmed: <span :class="isConfirmed ? 'text-green-700' : 'text-red-700'">{{ isConfirmed ? 'Yes' : 'No' }}</span>
</div>
<div class="text-sm text-gray-500">Expires: {{ approval.expires_at?.slice(0, 16) }}</div>
<button
@click="confirmApprovalAction"
:disabled="confirming || isConfirmed"
class="bg-green-600 text-white px-4 py-2 rounded text-sm"
>
{{ confirming ? 'Confirming...' : 'I Confirm' }}
</button>
<button
@click="sendOutbox"
:disabled="!canSend"
class="ml-2 bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ sending ? 'Sending...' : 'Send' }}
</button>
</div>
</section>
</template>
<div v-if="!loading && !application" class="text-gray-500">Application not found.</div>
</div>
</template>