308 lines
No EOL
10 KiB
Vue
308 lines
No EOL
10 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 { JobPosting, Cluster } from '@/types'
|
|
|
|
const toast = useToastStore()
|
|
|
|
const postings = ref<JobPosting[]>([])
|
|
const clusters = ref<Cluster[]>([])
|
|
const loading = ref(true)
|
|
const newUrl = ref('')
|
|
const scoringId = ref<string | null>(null)
|
|
const scoreMap = ref<Record<string, { score: number; rationale: Record<string, unknown> }>>({})
|
|
const redFlagsMap = ref<Record<string, string[]>>({})
|
|
const expandedClusters = ref<Set<string>>(new Set())
|
|
|
|
// Fetch form
|
|
const fetchQuery = ref('')
|
|
const fetchRegion = ref('')
|
|
const fetching = ref(false)
|
|
const fetchResult = ref<{ new: number; dupes: number } | null>(null)
|
|
|
|
// Group postings by cluster_id from the posting data. Postings without cluster_id get unique singleton groups.
|
|
const groupedPostings = computed(() => {
|
|
const map = new Map<string, JobPosting[]>()
|
|
for (const p of postings.value) {
|
|
const cid = (p as JobPosting & { cluster_id?: string }).cluster_id ?? `solo-${p.id}`
|
|
if (!map.has(cid)) map.set(cid, [])
|
|
map.get(cid)!.push(p)
|
|
}
|
|
return Array.from(map.entries()).map(([cluster_id, items]) => ({ cluster_id, items }))
|
|
})
|
|
|
|
// Best score per cluster (from cluster endpoint or from scoreMap)
|
|
function clusterBestScore(clusterId: string): number | null {
|
|
const cluster = clusters.value.find((c) => c.cluster_id === clusterId)
|
|
if (cluster && cluster.postings.length > 0) {
|
|
return Math.max(...cluster.postings.map((p) => p.score))
|
|
}
|
|
const items = groupedPostings.value.find((g) => g.cluster_id === clusterId)?.items
|
|
if (items) {
|
|
const scores = items.map((p) => scoreMap.value[p.id]?.score).filter((s): s is number => s != null)
|
|
return scores.length > 0 ? Math.max(...scores) : null
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Alternates for a cluster (from GET /clusters endpoint)
|
|
function clusterAlternatives(clusterId: string): Cluster['postings'] {
|
|
const cluster = clusters.value.find((c) => c.cluster_id === clusterId)
|
|
if (!cluster) return []
|
|
// Return postings other than the first/best one
|
|
return cluster.postings.slice(1)
|
|
}
|
|
|
|
function isExpanded(clusterId: string): boolean {
|
|
return expandedClusters.value.has(clusterId)
|
|
}
|
|
|
|
function toggleExpand(clusterId: string) {
|
|
const next = new Set(expandedClusters.value)
|
|
if (next.has(clusterId)) {
|
|
next.delete(clusterId)
|
|
} else {
|
|
next.add(clusterId)
|
|
}
|
|
expandedClusters.value = next
|
|
}
|
|
|
|
async function loadPostings() {
|
|
try {
|
|
const [postingsRes, clustersRes] = await Promise.allSettled([
|
|
api.getPostings(),
|
|
api.getClusters()
|
|
])
|
|
if (postingsRes.status === 'fulfilled') {
|
|
postings.value = postingsRes.value
|
|
}
|
|
if (clustersRes.status === 'fulfilled') {
|
|
clusters.value = clustersRes.value
|
|
}
|
|
// Load red flags for existing postings via batch scoring
|
|
if (postings.value.length > 0) {
|
|
try {
|
|
const batch = await api.batchScore(postings.value.map((p) => p.id))
|
|
const map: Record<string, string[]> = {}
|
|
for (const r of batch.results) {
|
|
if (r.red_flags && r.red_flags.length > 0) {
|
|
map[r.application_id] = r.red_flags
|
|
}
|
|
}
|
|
redFlagsMap.value = map
|
|
} catch {
|
|
// batch scoring is optional; ignore errors
|
|
}
|
|
}
|
|
} 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 doFetch() {
|
|
if (!fetchQuery.value.trim()) return
|
|
fetching.value = true
|
|
fetchResult.value = null
|
|
try {
|
|
fetchResult.value = await api.fetchPostings(fetchQuery.value.trim(), fetchRegion.value.trim() || undefined)
|
|
toast.push(`Fetched ${fetchResult.value.new} new postings`, 'success')
|
|
// Reload postings to show new ones
|
|
await loadPostings()
|
|
} catch (err) {
|
|
let msg = 'Fetch failed'
|
|
if (err instanceof HttpError) {
|
|
const body = err.body as { error?: { message?: string } } | null
|
|
msg = body?.error?.message ?? msg
|
|
}
|
|
toast.push(msg, 'error')
|
|
} finally {
|
|
fetching.value = false
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
function hasScamFlag(p: JobPosting): boolean {
|
|
const flags = redFlagsMap.value[p.id]
|
|
return flags != null && flags.length > 0
|
|
}
|
|
|
|
function scamFlagsFor(p: JobPosting): string[] {
|
|
return redFlagsMap.value[p.id] ?? []
|
|
}
|
|
|
|
onMounted(loadPostings)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-6">
|
|
<h1 class="text-2xl font-bold">Research</h1>
|
|
|
|
<!-- Fetch form (Arbetsformedlingen connector) -->
|
|
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
|
|
<h2 class="font-semibold">Fetch from Arbetsformedlingen</h2>
|
|
<div class="flex gap-2">
|
|
<input
|
|
v-model="fetchQuery"
|
|
placeholder="Search query (e.g. python developer)"
|
|
class="flex-1 border rounded px-2 py-1"
|
|
@keyup.enter="doFetch"
|
|
/>
|
|
<input
|
|
v-model="fetchRegion"
|
|
placeholder="Region (optional)"
|
|
class="w-48 border rounded px-2 py-1"
|
|
@keyup.enter="doFetch"
|
|
/>
|
|
<button
|
|
@click="doFetch"
|
|
:disabled="fetching"
|
|
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
|
>
|
|
{{ fetching ? 'Fetching...' : 'Fetch Postings' }}
|
|
</button>
|
|
</div>
|
|
<div v-if="fetchResult" class="text-sm">
|
|
<span class="text-green-700 font-medium">{{ fetchResult.new }} new</span>,
|
|
<span class="text-gray-500">{{ fetchResult.dupes }} duplicates</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Manual URL add -->
|
|
<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>
|
|
|
|
<!-- Cluster grouped postings -->
|
|
<div v-if="!loading" class="space-y-4">
|
|
<div
|
|
v-for="group in groupedPostings"
|
|
:key="group.cluster_id"
|
|
class="bg-white rounded-lg border border-gray-200"
|
|
>
|
|
<!-- Cluster header -->
|
|
<div
|
|
v-if="clusterAlternatives(group.cluster_id).length > 0"
|
|
class="flex items-center justify-between px-4 py-2 border-b border-gray-100 cursor-pointer hover:bg-gray-50"
|
|
@click="toggleExpand(group.cluster_id)"
|
|
>
|
|
<span class="text-sm font-medium text-gray-700">
|
|
{{ group.items[0]?.company ?? 'Unknown' }} - {{ group.items[0]?.title ?? 'No title' }}
|
|
</span>
|
|
<span class="text-xs text-gray-500" data-testid="cluster-alternates-toggle">
|
|
Also via {{ clusterAlternatives(group.cluster_id).length }} more
|
|
<span v-if="isExpanded(group.cluster_id)">▲</span>
|
|
<span v-else>▼</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Main posting table for this cluster -->
|
|
<table class="w-full 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">Scam</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 group.items" :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">
|
|
<span
|
|
v-if="hasScamFlag(p)"
|
|
class="text-red-600 font-bold"
|
|
:title="scamFlagsFor(p).join('; ')"
|
|
>
|
|
⚠
|
|
</span>
|
|
<span v-else class="text-gray-400">-</span>
|
|
</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>
|
|
|
|
<!-- Expandable alternates -->
|
|
<div
|
|
v-if="isExpanded(group.cluster_id) && clusterAlternatives(group.cluster_id).length > 0"
|
|
class="border-t border-gray-100 px-4 py-3 bg-gray-50"
|
|
data-testid="cluster-alternates"
|
|
>
|
|
<div class="text-xs font-medium text-gray-500 mb-2">Alternate sources for this role:</div>
|
|
<ul class="text-sm space-y-1">
|
|
<li
|
|
v-for="alt in clusterAlternatives(group.cluster_id)"
|
|
:key="alt.id"
|
|
class="flex items-center justify-between"
|
|
>
|
|
<span>
|
|
<a :href="alt.url" target="_blank" rel="noopener" class="text-indigo-600 hover:underline">
|
|
{{ alt.company }}
|
|
</a>
|
|
<span class="text-gray-400 ml-2">({{ alt.source }})</span>
|
|
</span>
|
|
<span v-if="alt.score" class="text-xs bg-green-100 text-green-800 rounded px-2 py-0.5">
|
|
{{ alt.score }}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template> |