Merge branch 'feat/T3-web'
This commit is contained in:
commit
8eb8400bad
25 changed files with 7068 additions and 0 deletions
12
apps/web/index.html
Normal file
12
apps/web/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jobhunt Platform</title>
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-900">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
5431
apps/web/package-lock.json
generated
Normal file
5431
apps/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
apps/web/package.json
Normal file
29
apps/web/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "jobhunt-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.2.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^2.1.8",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
23
apps/web/src/App.vue
Normal file
23
apps/web/src/App.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import ToastHost from './components/ToastHost.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-6">
|
||||
<span class="text-lg font-bold text-indigo-700">Jobhunt</span>
|
||||
<nav class="flex gap-4 text-sm">
|
||||
<RouterLink to="/cv" class="text-gray-600 hover:text-indigo-700">CV</RouterLink>
|
||||
<RouterLink to="/research" class="text-gray-600 hover:text-indigo-700">Research</RouterLink>
|
||||
<RouterLink to="/applications" class="text-gray-600 hover:text-indigo-700">Applications</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
|
||||
<RouterView />
|
||||
</main>
|
||||
<ToastHost />
|
||||
</div>
|
||||
</template>
|
||||
170
apps/web/src/api/index.ts
Normal file
170
apps/web/src/api/index.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// API client module: thin typed fetch wrappers over the contract endpoints.
|
||||
// Base URL from VITE_API_BASE, defaulting to http://localhost:8000/api.
|
||||
|
||||
import type {
|
||||
AiAssistResponse,
|
||||
Application,
|
||||
Approval,
|
||||
Artifact,
|
||||
CoverLetterResponse,
|
||||
CritiqueComment,
|
||||
CvSection,
|
||||
JobPosting,
|
||||
Profile,
|
||||
RenderCvResponse,
|
||||
ScoreResponse
|
||||
} from '@/types'
|
||||
|
||||
const API_BASE: string =
|
||||
import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api'
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, body: unknown, message?: string) {
|
||||
super(message ?? `HTTP ${status}`)
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options
|
||||
})
|
||||
if (!res.ok) {
|
||||
let body: unknown = null
|
||||
try {
|
||||
body = await res.json()
|
||||
} catch {
|
||||
// non-JSON error body
|
||||
}
|
||||
throw new HttpError(res.status, body, `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// --- Profile & CV ---
|
||||
|
||||
export function getProfile(): Promise<Profile> {
|
||||
return request<Profile>('/profile')
|
||||
}
|
||||
|
||||
export function updateProfile(data: Partial<Profile>): Promise<Profile> {
|
||||
return request<Profile>('/profile', { method: 'PUT', body: JSON.stringify(data) })
|
||||
}
|
||||
|
||||
export function getSections(): Promise<CvSection[]> {
|
||||
return request<CvSection[]>('/profile/sections')
|
||||
}
|
||||
|
||||
export function createSection(data: Partial<CvSection>): Promise<CvSection> {
|
||||
return request<CvSection>('/profile/sections', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
|
||||
export function updateSection(id: string, data: Partial<CvSection>): Promise<CvSection> {
|
||||
return request<CvSection>(`/profile/sections/${id}`, { method: 'PUT', body: JSON.stringify(data) })
|
||||
}
|
||||
|
||||
export function deleteSection(id: string): Promise<void> {
|
||||
return request<void>(`/profile/sections/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function aiAssist(sectionId: string, instruction: string): Promise<AiAssistResponse> {
|
||||
return request<AiAssistResponse>(`/profile/sections/${sectionId}/ai-assist`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ instruction })
|
||||
})
|
||||
}
|
||||
|
||||
export function renderCv(): Promise<RenderCvResponse> {
|
||||
return request<RenderCvResponse>('/profile/render-cv', { method: 'POST' })
|
||||
}
|
||||
|
||||
// --- Job postings ---
|
||||
|
||||
export function getPostings(): Promise<JobPosting[]> {
|
||||
return request<JobPosting[]>('/postings')
|
||||
}
|
||||
|
||||
export function createPosting(url: string): Promise<JobPosting> {
|
||||
return request<JobPosting>('/postings', { method: 'POST', body: JSON.stringify({ url }) })
|
||||
}
|
||||
|
||||
export function scorePosting(postingId: string): Promise<ScoreResponse> {
|
||||
return request<ScoreResponse>(`/postings/${postingId}/score`, { method: 'POST' })
|
||||
}
|
||||
|
||||
// --- Applications ---
|
||||
|
||||
export function getApplications(): Promise<Application[]> {
|
||||
return request<Application[]>('/applications')
|
||||
}
|
||||
|
||||
export function transitionApplication(id: string, to: string): Promise<Application> {
|
||||
return request<Application>(`/applications/${id}/transition`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ to })
|
||||
})
|
||||
}
|
||||
|
||||
export function getArtifacts(applicationId: string): Promise<Artifact[]> {
|
||||
return request<Artifact[]>(`/applications/${applicationId}/artifacts`)
|
||||
}
|
||||
|
||||
export function createCoverLetter(
|
||||
applicationId: string,
|
||||
letterText: string
|
||||
): Promise<CoverLetterResponse> {
|
||||
return request<CoverLetterResponse>(`/applications/${applicationId}/artifacts/cover-letter`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ letter_text: letterText })
|
||||
})
|
||||
}
|
||||
|
||||
// --- Approval & outbox ---
|
||||
|
||||
export function createApproval(
|
||||
applicationId: string,
|
||||
action: string,
|
||||
artifactId: string
|
||||
): Promise<Approval> {
|
||||
return request<Approval>(`/applications/${applicationId}/approvals`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, artifact_id: artifactId })
|
||||
})
|
||||
}
|
||||
|
||||
export function confirmApproval(approvalId: string): Promise<Approval> {
|
||||
return request<Approval>(`/approvals/${approvalId}/confirm`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function rejectApproval(approvalId: string): Promise<Approval> {
|
||||
return request<Approval>(`/approvals/${approvalId}/reject`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function outboxSend(approvalId: string, payload: Record<string, unknown>): Promise<unknown> {
|
||||
return request<unknown>('/outbox/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ approval_id: approvalId, payload })
|
||||
})
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
AiAssistResponse,
|
||||
Application,
|
||||
Approval,
|
||||
Artifact,
|
||||
CoverLetterResponse,
|
||||
CritiqueComment,
|
||||
CvSection,
|
||||
JobPosting,
|
||||
Profile,
|
||||
RenderCvResponse,
|
||||
ScoreResponse
|
||||
}
|
||||
21
apps/web/src/components/ToastHost.vue
Normal file
21
apps/web/src/components/ToastHost.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
const toastStore = useToastStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="t in toastStore.toasts"
|
||||
:key="t.id"
|
||||
class="px-4 py-2 rounded shadow-lg text-sm text-white max-w-sm"
|
||||
:class="{
|
||||
'bg-gray-800': t.type === 'info',
|
||||
'bg-red-600': t.type === 'error',
|
||||
'bg-green-600': t.type === 'success'
|
||||
}"
|
||||
>
|
||||
{{ t.message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
10
apps/web/src/main.ts
Normal file
10
apps/web/src/main.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
33
apps/web/src/router.test.ts
Normal file
33
apps/web/src/router.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import App from '@/App.vue'
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/cv' },
|
||||
{ path: '/cv', name: 'cv', component: { template: '<div>CV</div>' } },
|
||||
{ path: '/research', name: 'research', component: { template: '<div>Research</div>' } },
|
||||
{ path: '/applications', name: 'applications', component: { template: '<div>Applications</div>' } },
|
||||
{ path: '/applications/:id', name: 'application-detail', component: { template: '<div>Detail</div>' } }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
describe('Router tabs', () => {
|
||||
it('renders all three tab links', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const router = makeRouter()
|
||||
await router.push('/cv')
|
||||
await router.isReady()
|
||||
const wrapper = mount(App, { global: { plugins: [router] } })
|
||||
const links = wrapper.findAll('nav a')
|
||||
expect(links).toHaveLength(3)
|
||||
expect(links[0].text()).toBe('CV')
|
||||
expect(links[1].text()).toBe('Research')
|
||||
expect(links[2].text()).toBe('Applications')
|
||||
})
|
||||
})
|
||||
34
apps/web/src/router/index.ts
Normal file
34
apps/web/src/router/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/cv' },
|
||||
{
|
||||
path: '/cv',
|
||||
name: 'cv',
|
||||
component: () => import('@/views/CvEditor.vue')
|
||||
},
|
||||
{
|
||||
path: '/research',
|
||||
name: 'research',
|
||||
component: () => import('@/views/Research.vue')
|
||||
},
|
||||
{
|
||||
path: '/applications',
|
||||
name: 'applications',
|
||||
component: () => import('@/views/Applications.vue')
|
||||
},
|
||||
{
|
||||
path: '/applications/:id',
|
||||
name: 'application-detail',
|
||||
component: () => import('@/views/ApplicationDetail.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
28
apps/web/src/stores/toast.ts
Normal file
28
apps/web/src/stores/toast.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: 'info' | 'error' | 'success'
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
|
||||
export const useToastStore = defineStore('toast', () => {
|
||||
const toasts = ref<Toast[]>([])
|
||||
|
||||
function push(message: string, type: Toast['type'] = 'info'): number {
|
||||
const id = ++nextId
|
||||
toasts.value.push({ id, message, type })
|
||||
setTimeout(() => dismiss(id), 5000)
|
||||
return id
|
||||
}
|
||||
|
||||
function dismiss(id: number): void {
|
||||
const idx = toasts.value.findIndex((t) => t.id === id)
|
||||
if (idx !== -1) toasts.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
return { toasts, push, dismiss }
|
||||
})
|
||||
3
apps/web/src/style.css
Normal file
3
apps/web/src/style.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
128
apps/web/src/types/index.ts
Normal file
128
apps/web/src/types/index.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// Domain types matching the API contract (docs/api-contract.md + docs/data-model.md)
|
||||
|
||||
export interface Profile {
|
||||
id: string
|
||||
full_name: string
|
||||
email: string
|
||||
phone: string
|
||||
location: string
|
||||
headline: string
|
||||
summary: string
|
||||
languages: { code: string; level: string }[]
|
||||
hard_rules: Record<string, unknown>
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export type CvSectionKind = 'experience' | 'education' | 'skills' | 'projects' | 'other'
|
||||
|
||||
export interface CvSection {
|
||||
id: string
|
||||
profile_id: string
|
||||
kind: CvSectionKind
|
||||
title: string
|
||||
org: string
|
||||
location: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
bullets: string[]
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface JobPosting {
|
||||
id: string
|
||||
source: string
|
||||
external_id: string | null
|
||||
url: string
|
||||
company: string
|
||||
title: string
|
||||
location: string
|
||||
description: string
|
||||
raw: Record<string, unknown>
|
||||
fetched_at: string
|
||||
}
|
||||
|
||||
export type ApplicationState =
|
||||
| 'discovered'
|
||||
| 'scored'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'drafting'
|
||||
| 'sent'
|
||||
| 'interviewing'
|
||||
| 'offer'
|
||||
| 'closed'
|
||||
| 'expired'
|
||||
|
||||
export interface Application {
|
||||
id: string
|
||||
job_posting_id: string
|
||||
state: ApplicationState
|
||||
score: number | null
|
||||
score_rationale: Record<string, unknown> | null
|
||||
notes: string
|
||||
state_changed_at: string
|
||||
created_at: string
|
||||
// joined posting info (from GET /applications)
|
||||
posting?: JobPosting
|
||||
}
|
||||
|
||||
export type ArtifactKind = 'cv' | 'cover_letter' | 'email' | 'other'
|
||||
|
||||
export interface Artifact {
|
||||
id: string
|
||||
application_id: string
|
||||
kind: ArtifactKind
|
||||
filename: string
|
||||
content_hash: string
|
||||
storage_path: string
|
||||
version: number
|
||||
origin: 'user_drafted' | 'ai_drafted' | 'ai_reviewed'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type ApprovalAction = 'send_email' | 'submit_application'
|
||||
|
||||
export interface Approval {
|
||||
id: string
|
||||
application_id: string
|
||||
artifact_id: string
|
||||
artifact_hash: string
|
||||
action: ApprovalAction
|
||||
confirmed_by_user: boolean
|
||||
confirmed_at: string | null
|
||||
expires_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CritiqueComment {
|
||||
quote: string
|
||||
suggestion: string
|
||||
severity: 'high' | 'medium' | 'low'
|
||||
}
|
||||
|
||||
export interface CoverLetterResponse {
|
||||
artifact: Artifact
|
||||
comments: CritiqueComment[]
|
||||
}
|
||||
|
||||
export interface ScoreResponse {
|
||||
score: number
|
||||
rationale: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RenderCvResponse {
|
||||
artifact_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface AiAssistResponse {
|
||||
suggestions: string[]
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
153
apps/web/src/views/ApplicationDetail.test.ts
Normal file
153
apps/web/src/views/ApplicationDetail.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import type { Application, Artifact, Approval } from '@/types'
|
||||
|
||||
// Mock the api module inline so vi.mock factory is self-contained
|
||||
vi.mock('@/api', () => ({
|
||||
getApplications: vi.fn(),
|
||||
getArtifacts: vi.fn(),
|
||||
createCoverLetter: vi.fn(),
|
||||
createApproval: vi.fn(),
|
||||
confirmApproval: vi.fn(),
|
||||
rejectApproval: vi.fn(),
|
||||
outboxSend: vi.fn(),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, body: unknown, msg?: string) {
|
||||
super(msg ?? `HTTP ${status}`)
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
function makeApp(): Application {
|
||||
return {
|
||||
id: 'app-1',
|
||||
job_posting_id: 'j-1',
|
||||
state: 'drafting',
|
||||
score: 90,
|
||||
score_rationale: null,
|
||||
notes: '',
|
||||
state_changed_at: '2026-01-01T00:00:00Z',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
posting: {
|
||||
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
|
||||
company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
|
||||
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeArtifact(): Artifact {
|
||||
return {
|
||||
id: 'art-1',
|
||||
application_id: 'app-1',
|
||||
kind: 'cover_letter',
|
||||
filename: 'cover.pdf',
|
||||
content_hash: 'abcdef0123456789',
|
||||
storage_path: '/tmp/cover.pdf',
|
||||
version: 1,
|
||||
origin: 'user_drafted',
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
function makeApproval(confirmed: boolean): Approval {
|
||||
return {
|
||||
id: 'appr-1',
|
||||
application_id: 'app-1',
|
||||
artifact_id: 'art-1',
|
||||
artifact_hash: 'abcdef0123456789',
|
||||
action: 'send_email',
|
||||
confirmed_by_user: confirmed,
|
||||
confirmed_at: confirmed ? '2026-01-01T00:00:00Z' : null,
|
||||
expires_at: '2026-01-02T00:00:00Z',
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
async function mountDetail(app: Application, artifacts: Artifact[]) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const api = await import('@/api')
|
||||
;(api.getApplications as ReturnType<typeof vi.fn>).mockResolvedValue([app])
|
||||
;(api.getArtifacts as ReturnType<typeof vi.fn>).mockResolvedValue(artifacts)
|
||||
const ApplicationDetail = (await import('@/views/ApplicationDetail.vue')).default
|
||||
const wrapper = mount(ApplicationDetail, { props: { id: 'app-1' } })
|
||||
await flushPromises()
|
||||
return { wrapper, pinia }
|
||||
}
|
||||
|
||||
/** Find the Send button (has class ml-2 to distinguish from the Save button) */
|
||||
function findSendBtn(wrapper: ReturnType<typeof mount>): ReturnType<typeof wrapper.find> {
|
||||
return wrapper.find('button.ml-2')
|
||||
}
|
||||
|
||||
describe('Approval widget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('disables Send until confirmed', async () => {
|
||||
const { wrapper } = await mountDetail(makeApp(), [makeArtifact()])
|
||||
const api = await import('@/api')
|
||||
|
||||
// Request approval (not confirmed yet)
|
||||
;(api.createApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(false))
|
||||
|
||||
// Select artifact and action
|
||||
const selects = wrapper.findAll('select')
|
||||
await selects[0].setValue('art-1')
|
||||
await selects[1].setValue('send_email')
|
||||
|
||||
// Click Request Approval (bg-gray-700)
|
||||
await wrapper.find('button.bg-gray-700').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Send button should be disabled because not confirmed
|
||||
const sendBtn = findSendBtn(wrapper)
|
||||
expect(sendBtn.exists()).toBe(true)
|
||||
expect(sendBtn.attributes('disabled')).toBeDefined()
|
||||
|
||||
// Now confirm
|
||||
;(api.confirmApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(true))
|
||||
const confirmBtn = wrapper.find('button.bg-green-600')
|
||||
await confirmBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Send button should now be enabled
|
||||
expect(sendBtn.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows error toast on 409 send failure', async () => {
|
||||
const { wrapper, pinia } = await mountDetail(makeApp(), [makeArtifact()])
|
||||
const api = await import('@/api')
|
||||
|
||||
// Setup: approval already confirmed
|
||||
;(api.createApproval as ReturnType<typeof vi.fn>).mockResolvedValue(makeApproval(true))
|
||||
const selects = wrapper.findAll('select')
|
||||
await selects[0].setValue('art-1')
|
||||
await selects[1].setValue('send_email')
|
||||
await wrapper.find('button.bg-gray-700').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Simulate 409 on send
|
||||
const httpErr = new api.HttpError(409, { error: { code: 'HASH_MISMATCH', message: 'Hash mismatch' } })
|
||||
;(api.outboxSend as ReturnType<typeof vi.fn>).mockRejectedValue(httpErr)
|
||||
|
||||
const sendBtn = findSendBtn(wrapper)
|
||||
await sendBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Check the toast store for the error message
|
||||
const toastState = pinia.state.value.toast
|
||||
expect(toastState).toBeTruthy()
|
||||
expect(toastState.toasts.length).toBeGreaterThanOrEqual(1)
|
||||
const errorToasts = toastState.toasts.filter((t: { type: string }) => t.type === 'error')
|
||||
expect(errorToasts.length).toBeGreaterThanOrEqual(1)
|
||||
expect(errorToasts[0].message).toContain('Hash mismatch')
|
||||
})
|
||||
})
|
||||
251
apps/web/src/views/ApplicationDetail.vue
Normal file
251
apps/web/src/views/ApplicationDetail.vue
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<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>
|
||||
82
apps/web/src/views/Applications.test.ts
Normal file
82
apps/web/src/views/Applications.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import Applications from '@/views/Applications.vue'
|
||||
import type { Application } from '@/types'
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
getApplications: vi.fn(),
|
||||
transitionApplication: vi.fn(),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, body: unknown, msg?: string) {
|
||||
super(msg ?? `HTTP ${status}`)
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
function fixture(): Application[] {
|
||||
return [
|
||||
{
|
||||
id: 'app-1',
|
||||
job_posting_id: 'j-1',
|
||||
state: 'discovered',
|
||||
score: null,
|
||||
score_rationale: null,
|
||||
notes: '',
|
||||
state_changed_at: '2026-01-01T00:00:00Z',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
posting: {
|
||||
id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x',
|
||||
company: 'Acme', title: 'Engineer', location: 'Remote', description: '',
|
||||
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'app-2',
|
||||
job_posting_id: 'j-2',
|
||||
state: 'scored',
|
||||
score: 85,
|
||||
score_rationale: null,
|
||||
notes: '',
|
||||
state_changed_at: '2026-01-01T00:00:00Z',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
posting: {
|
||||
id: 'j-2', source: 'linkedin', external_id: null, url: 'http://y',
|
||||
company: 'Globex', title: 'Manager', location: 'Malmo', description: '',
|
||||
raw: {}, fetched_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('Applications kanban', () => {
|
||||
it('groups cards by state from fixture', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const { getApplications } = await import('@/api')
|
||||
;(getApplications as ReturnType<typeof vi.fn>).mockResolvedValue(fixture())
|
||||
|
||||
const wrapper = mount(Applications)
|
||||
// Wait for onMounted load
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Acme')
|
||||
expect(wrapper.text()).toContain('Globex')
|
||||
})
|
||||
|
||||
// "discovered" column should contain Acme
|
||||
const discoveredCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'discovered')
|
||||
expect(discoveredCol).toBeTruthy()
|
||||
const discoveredColumn = discoveredCol!.element.parentElement!
|
||||
expect(discoveredColumn.textContent).toContain('Acme')
|
||||
expect(discoveredColumn.textContent).not.toContain('Globex')
|
||||
|
||||
// "scored" column should contain Globex
|
||||
const scoredCol = wrapper.findAll('.font-semibold').find((el) => el.text() === 'scored')
|
||||
expect(scoredCol).toBeTruthy()
|
||||
const scoredColumn = scoredCol!.element.parentElement!
|
||||
expect(scoredColumn.textContent).toContain('Globex')
|
||||
})
|
||||
})
|
||||
133
apps/web/src/views/Applications.vue
Normal file
133
apps/web/src/views/Applications.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import * as api from '@/api'
|
||||
import { HttpError } from '@/api'
|
||||
import type { Application, ApplicationState } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
const router = useRouter()
|
||||
|
||||
const applications = ref<Application[]>([])
|
||||
const loading = ref(true)
|
||||
const draggingId = ref<string | null>(null)
|
||||
const draggingFrom = ref<ApplicationState | null>(null)
|
||||
|
||||
const states: ApplicationState[] = [
|
||||
'discovered',
|
||||
'scored',
|
||||
'approved',
|
||||
'drafting',
|
||||
'sent',
|
||||
'interviewing',
|
||||
'offer',
|
||||
'closed',
|
||||
'rejected',
|
||||
'expired'
|
||||
]
|
||||
|
||||
function appsInState(state: ApplicationState): Application[] {
|
||||
return applications.value.filter((a) => a.state === state)
|
||||
}
|
||||
|
||||
async function loadApplications() {
|
||||
try {
|
||||
applications.value = await api.getApplications()
|
||||
} catch {
|
||||
toast.push('Failed to load applications', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(app: Application, state: ApplicationState, e: DragEvent) {
|
||||
draggingId.value = app.id
|
||||
draggingFrom.value = state
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', app.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(targetState: ApplicationState, e: DragEvent) {
|
||||
e.preventDefault()
|
||||
const appId = draggingId.value
|
||||
draggingId.value = null
|
||||
draggingFrom.value = null
|
||||
if (!appId) return
|
||||
|
||||
const app = applications.value.find((a) => a.id === appId)
|
||||
if (!app || app.state === targetState) return
|
||||
|
||||
// Optimistic: move card immediately
|
||||
const oldState = app.state
|
||||
app.state = targetState
|
||||
|
||||
try {
|
||||
await api.transitionApplication(appId, targetState)
|
||||
} catch (err) {
|
||||
// Revert on error (including 409)
|
||||
app.state = oldState
|
||||
let msg = 'Transition failed'
|
||||
if (err instanceof HttpError) {
|
||||
const body = err.body as { error?: { message?: string } } | null
|
||||
msg = body?.error?.message ?? `HTTP ${err.status}`
|
||||
if (err.status === 409) {
|
||||
msg = `Rejected: ${msg}`
|
||||
}
|
||||
}
|
||||
toast.push(msg, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function goToDetail(app: Application) {
|
||||
router.push(`/applications/${app.id}`)
|
||||
}
|
||||
|
||||
onMounted(loadApplications)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">Applications</h1>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Loading...</div>
|
||||
|
||||
<div v-if="!loading" class="flex gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="state in states"
|
||||
:key="state"
|
||||
class="flex-shrink-0 w-56 bg-gray-100 rounded-lg p-2 min-h-[200px]"
|
||||
@drop="onDrop(state, $event)"
|
||||
@dragover="onDragOver"
|
||||
>
|
||||
<div class="font-semibold text-sm text-gray-700 mb-2 capitalize">{{ state }}</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="app in appsInState(state)"
|
||||
:key="app.id"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(app, state, $event)"
|
||||
@click="goToDetail(app)"
|
||||
class="bg-white rounded border border-gray-200 p-2 cursor-pointer hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ app.posting?.title ?? 'No title' }}</div>
|
||||
<div v-if="app.score != null" class="text-xs text-green-700 mt-1">
|
||||
Score: {{ app.score }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="appsInState(state).length === 0" class="text-xs text-gray-400 italic py-2 text-center">
|
||||
-
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
338
apps/web/src/views/CvEditor.vue
Normal file
338
apps/web/src/views/CvEditor.vue
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<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>
|
||||
103
apps/web/src/views/Research.vue
Normal file
103
apps/web/src/views/Research.vue
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import * as api from '@/api'
|
||||
import type { JobPosting } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
|
||||
const postings = ref<JobPosting[]>([])
|
||||
const loading = ref(true)
|
||||
const newUrl = ref('')
|
||||
const scoringId = ref<string | null>(null)
|
||||
const scoreMap = ref<Record<string, { score: number; rationale: Record<string, unknown> }>>({})
|
||||
|
||||
async function loadPostings() {
|
||||
try {
|
||||
postings.value = await api.getPostings()
|
||||
} catch {
|
||||
toast.push('Failed to load postings', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addPosting() {
|
||||
if (!newUrl.value.trim()) return
|
||||
try {
|
||||
const p = await api.createPosting(newUrl.value.trim())
|
||||
postings.value.unshift(p)
|
||||
newUrl.value = ''
|
||||
toast.push('Posting added', 'success')
|
||||
} catch {
|
||||
toast.push('Failed to add posting', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function scorePosting(p: JobPosting) {
|
||||
scoringId.value = p.id
|
||||
try {
|
||||
const res = await api.scorePosting(p.id)
|
||||
scoreMap.value[p.id] = { score: res.score, rationale: res.rationale }
|
||||
toast.push(`Scored: ${res.score}`, 'success')
|
||||
} catch {
|
||||
toast.push('Scoring failed', 'error')
|
||||
} finally {
|
||||
scoringId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPostings)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold">Research</h1>
|
||||
|
||||
<section class="bg-white rounded-lg border border-gray-200 p-4 flex gap-2">
|
||||
<input
|
||||
v-model="newUrl"
|
||||
placeholder="Paste a job URL"
|
||||
class="flex-1 border rounded px-2 py-1"
|
||||
@keyup.enter="addPosting"
|
||||
/>
|
||||
<button @click="addPosting" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">Add by URL</button>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Loading...</div>
|
||||
|
||||
<table v-if="!loading" class="w-full bg-white rounded-lg border border-gray-200 text-sm">
|
||||
<thead class="bg-gray-50 text-left">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Company</th>
|
||||
<th class="px-3 py-2">Title</th>
|
||||
<th class="px-3 py-2">Location</th>
|
||||
<th class="px-3 py-2">Source</th>
|
||||
<th class="px-3 py-2">Fetched</th>
|
||||
<th class="px-3 py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in postings" :key="p.id" class="border-t border-gray-100">
|
||||
<td class="px-3 py-2">{{ p.company }}</td>
|
||||
<td class="px-3 py-2">{{ p.title }}</td>
|
||||
<td class="px-3 py-2">{{ p.location }}</td>
|
||||
<td class="px-3 py-2">{{ p.source }}</td>
|
||||
<td class="px-3 py-2 text-gray-500">{{ p.fetched_at?.slice(0, 10) }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<button
|
||||
@click="scorePosting(p)"
|
||||
:disabled="scoringId === p.id"
|
||||
class="text-indigo-600 hover:underline text-sm"
|
||||
>
|
||||
{{ scoringId === p.id ? 'Scoring...' : 'Score' }}
|
||||
</button>
|
||||
<span v-if="scoreMap[p.id]" class="ml-2 text-xs bg-green-100 text-green-800 rounded px-2 py-0.5">
|
||||
{{ scoreMap[p.id].score }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
7
apps/web/src/vite-env.d.ts
vendored
Normal file
7
apps/web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
8
apps/web/tailwind.config.js
Normal file
8
apps/web/tailwind.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,ts}'],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
}
|
||||
25
apps/web/tsconfig.json
Normal file
25
apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
apps/web/tsconfig.node.json
Normal file
11
apps/web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
12
apps/web/vite.config.ts
Normal file
12
apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
}
|
||||
})
|
||||
17
apps/web/vitest.config.ts
Normal file
17
apps/web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts']
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue