390 lines
No EOL
13 KiB
Vue
390 lines
No EOL
13 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 InterviewPrepModal from '@/components/InterviewPrepModal.vue'
|
|
import type {
|
|
Application,
|
|
Artifact,
|
|
Approval,
|
|
ApprovalAction,
|
|
CoverLetterResponse,
|
|
CritiqueComment,
|
|
TailorCvResponse
|
|
} 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)
|
|
|
|
// Interview prep modal
|
|
const showPrepModal = ref(false)
|
|
|
|
// Tailor CV
|
|
const tailoring = ref(false)
|
|
const tailorResult = ref<TailorCvResponse | null>(null)
|
|
|
|
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'
|
|
}
|
|
|
|
const coverageColor = computed(() => {
|
|
if (!tailorResult.value) return 'bg-gray-300'
|
|
const c = tailorResult.value.keyword_coverage
|
|
if (c >= 0.7) return 'bg-green-500'
|
|
if (c >= 0.4) return 'bg-yellow-500'
|
|
return 'bg-red-500'
|
|
})
|
|
|
|
const coveragePercent = computed(() => {
|
|
if (!tailorResult.value) return 0
|
|
return Math.round(tailorResult.value.keyword_coverage * 100)
|
|
})
|
|
|
|
const downloadUrl = computed(() => {
|
|
if (!tailorResult.value) return ''
|
|
return `${import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api'}/artifacts/${tailorResult.value.artifact_id}/download`
|
|
})
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
function openInterviewPrep() {
|
|
showPrepModal.value = true
|
|
}
|
|
|
|
function closeInterviewPrep() {
|
|
showPrepModal.value = false
|
|
}
|
|
|
|
async function tailorCv() {
|
|
tailoring.value = true
|
|
tailorResult.value = null
|
|
try {
|
|
const res = await api.tailorCv(props.id)
|
|
tailorResult.value = res
|
|
// Refresh artifacts to show the new tailored CV variant
|
|
artifacts.value = await api.getArtifacts(props.id)
|
|
toast.push('CV tailored for this job', 'success')
|
|
} catch (err) {
|
|
let msg = 'Failed to tailor CV'
|
|
if (err instanceof HttpError) {
|
|
const body = err.body as { error?: { message?: string } } | null
|
|
msg = body?.error?.message ?? msg
|
|
}
|
|
toast.push(msg, 'error')
|
|
} finally {
|
|
tailoring.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>
|
|
|
|
<!-- Tailor CV -->
|
|
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
<h2 class="font-semibold">Tailor CV for this Job</h2>
|
|
<p class="text-sm text-gray-600">
|
|
Generate a tailored CV variant that reorders and rephrases your existing sections toward this posting's keywords. Your facts are never invented, only rephrased.
|
|
</p>
|
|
<button
|
|
@click="tailorCv"
|
|
:disabled="tailoring"
|
|
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
|
data-testid="tailor-cv-btn"
|
|
>
|
|
{{ tailoring ? 'Tailoring...' : 'Tailor My CV' }}
|
|
</button>
|
|
|
|
<!-- Tailor result panel -->
|
|
<div v-if="tailorResult" class="space-y-4 border-t border-gray-100 pt-3" data-testid="tailor-panel">
|
|
<!-- Keyword coverage bar -->
|
|
<div>
|
|
<div class="flex items-center justify-between text-sm mb-1">
|
|
<span class="text-gray-600">Keyword Coverage</span>
|
|
<span class="font-medium">{{ coveragePercent }}%</span>
|
|
</div>
|
|
<div class="w-full bg-gray-200 rounded-full h-3">
|
|
<div
|
|
class="h-3 rounded-full transition-all"
|
|
:class="coverageColor"
|
|
:style="{ width: coveragePercent + '%' }"
|
|
data-testid="coverage-bar"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Change log -->
|
|
<div>
|
|
<h3 class="font-medium text-sm mb-2">Changes Made</h3>
|
|
<ul class="text-sm space-y-1">
|
|
<li
|
|
v-for="(entry, i) in tailorResult.change_log"
|
|
:key="i"
|
|
class="border-b border-gray-100 py-1"
|
|
>
|
|
<span class="font-medium text-gray-700">{{ entry.section }}:</span>
|
|
<span class="text-gray-600 ml-1">{{ entry.change }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Download link -->
|
|
<div>
|
|
<a
|
|
:href="downloadUrl"
|
|
target="_blank"
|
|
rel="noopener"
|
|
class="text-sm text-indigo-600 hover:underline"
|
|
data-testid="download-link"
|
|
>
|
|
Download tailored CV (PDF)
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Interview prep -->
|
|
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
<h2 class="font-semibold">Interview Prep</h2>
|
|
<p class="text-sm text-gray-600">
|
|
Generate likely interview questions with suggested answers based on your profile and the posting.
|
|
</p>
|
|
<button
|
|
@click="openInterviewPrep"
|
|
class="bg-purple-600 text-white px-4 py-2 rounded text-sm"
|
|
>
|
|
Open Interview Prep
|
|
</button>
|
|
</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>
|
|
|
|
<!-- Interview prep modal -->
|
|
<InterviewPrepModal
|
|
:application-id="id"
|
|
:visible="showPrepModal"
|
|
@close="closeInterviewPrep"
|
|
/>
|
|
</div>
|
|
</template> |