W3: add red-flag badges + nudge dots to kanban, interview-prep modal to detail, fetch form + scam column to Research
This commit is contained in:
parent
e9b3b37e0d
commit
f43ede9e07
3 changed files with 178 additions and 2 deletions
|
|
@ -3,6 +3,7 @@ import { onMounted, ref, computed } from 'vue'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import * as api from '@/api'
|
import * as api from '@/api'
|
||||||
import { HttpError } from '@/api'
|
import { HttpError } from '@/api'
|
||||||
|
import InterviewPrepModal from '@/components/InterviewPrepModal.vue'
|
||||||
import type {
|
import type {
|
||||||
Application,
|
Application,
|
||||||
Artifact,
|
Artifact,
|
||||||
|
|
@ -32,6 +33,9 @@ const requestingApproval = ref(false)
|
||||||
const confirming = ref(false)
|
const confirming = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
|
|
||||||
|
// Interview prep modal
|
||||||
|
const showPrepModal = ref(false)
|
||||||
|
|
||||||
const isConfirmed = computed(() => approval.value?.confirmed_by_user ?? false)
|
const isConfirmed = computed(() => approval.value?.confirmed_by_user ?? false)
|
||||||
const canSend = computed(() => isConfirmed.value && !sending.value)
|
const canSend = computed(() => isConfirmed.value && !sending.value)
|
||||||
|
|
||||||
|
|
@ -127,6 +131,14 @@ async function sendOutbox() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openInterviewPrep() {
|
||||||
|
showPrepModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInterviewPrep() {
|
||||||
|
showPrepModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadData)
|
onMounted(loadData)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -147,6 +159,20 @@ onMounted(loadData)
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Artifacts list -->
|
||||||
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-2">
|
<section class="bg-white rounded-lg border border-gray-200 p-4 space-y-2">
|
||||||
<h2 class="font-semibold">Artifacts</h2>
|
<h2 class="font-semibold">Artifacts</h2>
|
||||||
|
|
@ -247,5 +273,12 @@ onMounted(loadData)
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="!loading && !application" class="text-gray-500">Application not found.</div>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import * as api from '@/api'
|
import * as api from '@/api'
|
||||||
import { HttpError } from '@/api'
|
import { HttpError } from '@/api'
|
||||||
import type { Application, ApplicationState } from '@/types'
|
import type { Application, ApplicationState, TodayNudge } from '@/types'
|
||||||
|
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -14,6 +14,10 @@ const loading = ref(true)
|
||||||
const draggingId = ref<string | null>(null)
|
const draggingId = ref<string | null>(null)
|
||||||
const draggingFrom = ref<ApplicationState | null>(null)
|
const draggingFrom = ref<ApplicationState | null>(null)
|
||||||
|
|
||||||
|
// Red flags and nudges
|
||||||
|
const redFlagsMap = ref<Record<string, string[]>>({})
|
||||||
|
const nudgeIds = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const states: ApplicationState[] = [
|
const states: ApplicationState[] = [
|
||||||
'discovered',
|
'discovered',
|
||||||
'scored',
|
'scored',
|
||||||
|
|
@ -31,9 +35,39 @@ function appsInState(state: ApplicationState): Application[] {
|
||||||
return applications.value.filter((a) => a.state === state)
|
return applications.value.filter((a) => a.state === state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRedFlags(app: Application): boolean {
|
||||||
|
const flags = redFlagsMap.value[app.id]
|
||||||
|
return flags != null && flags.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function redFlagsFor(app: Application): string[] {
|
||||||
|
return redFlagsMap.value[app.id] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNudge(app: Application): boolean {
|
||||||
|
return nudgeIds.value.has(app.id)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadApplications() {
|
async function loadApplications() {
|
||||||
try {
|
try {
|
||||||
applications.value = await api.getApplications()
|
applications.value = await api.getApplications()
|
||||||
|
// Load red flags via batch scoring and nudges via today endpoint
|
||||||
|
const [batchResult, todayResult] = await Promise.allSettled([
|
||||||
|
api.batchScore(applications.value.map((a) => a.id)),
|
||||||
|
api.getToday()
|
||||||
|
])
|
||||||
|
if (batchResult.status === 'fulfilled') {
|
||||||
|
const map: Record<string, string[]> = {}
|
||||||
|
for (const r of batchResult.value.results) {
|
||||||
|
if (r.red_flags && r.red_flags.length > 0) {
|
||||||
|
map[r.application_id] = r.red_flags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redFlagsMap.value = map
|
||||||
|
}
|
||||||
|
if (todayResult.status === 'fulfilled') {
|
||||||
|
nudgeIds.value = new Set(todayResult.value.nudges.map((n: TodayNudge) => n.application_id))
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.push('Failed to load applications', 'error')
|
toast.push('Failed to load applications', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -117,7 +151,21 @@ onMounted(loadApplications)
|
||||||
@click="goToDetail(app)"
|
@click="goToDetail(app)"
|
||||||
class="bg-white rounded border border-gray-200 p-2 cursor-pointer hover:shadow-md transition-shadow"
|
class="bg-white rounded border border-gray-200 p-2 cursor-pointer hover:shadow-md transition-shadow"
|
||||||
>
|
>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
v-if="hasRedFlags(app)"
|
||||||
|
class="text-red-600 font-bold text-sm flex-shrink-0"
|
||||||
|
:title="redFlagsFor(app).join('; ')"
|
||||||
|
>
|
||||||
|
⚠
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="hasNudge(app)"
|
||||||
|
class="inline-block w-2 h-2 rounded-full bg-orange-500 flex-shrink-0"
|
||||||
|
title="Follow-up nudge pending"
|
||||||
|
></span>
|
||||||
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div>
|
<div class="font-medium text-sm truncate">{{ app.posting?.company ?? 'Unknown' }}</div>
|
||||||
|
</div>
|
||||||
<div class="text-xs text-gray-500 truncate">{{ app.posting?.title ?? 'No title' }}</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">
|
<div v-if="app.score != null" class="text-xs text-green-700 mt-1">
|
||||||
Score: {{ app.score }}
|
Score: {{ app.score }}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import * as api from '@/api'
|
import * as api from '@/api'
|
||||||
|
import { HttpError } from '@/api'
|
||||||
import type { JobPosting } from '@/types'
|
import type { JobPosting } from '@/types'
|
||||||
|
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
@ -11,10 +12,32 @@ const loading = ref(true)
|
||||||
const newUrl = ref('')
|
const newUrl = ref('')
|
||||||
const scoringId = ref<string | null>(null)
|
const scoringId = ref<string | null>(null)
|
||||||
const scoreMap = ref<Record<string, { score: number; rationale: Record<string, unknown> }>>({})
|
const scoreMap = ref<Record<string, { score: number; rationale: Record<string, unknown> }>>({})
|
||||||
|
const redFlagsMap = ref<Record<string, string[]>>({})
|
||||||
|
|
||||||
|
// Fetch form
|
||||||
|
const fetchQuery = ref('')
|
||||||
|
const fetchRegion = ref('')
|
||||||
|
const fetching = ref(false)
|
||||||
|
const fetchResult = ref<{ new: number; dupes: number } | null>(null)
|
||||||
|
|
||||||
async function loadPostings() {
|
async function loadPostings() {
|
||||||
try {
|
try {
|
||||||
postings.value = await api.getPostings()
|
postings.value = await api.getPostings()
|
||||||
|
// 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 {
|
} catch {
|
||||||
toast.push('Failed to load postings', 'error')
|
toast.push('Failed to load postings', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -34,6 +57,27 @@ async function addPosting() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async function scorePosting(p: JobPosting) {
|
||||||
scoringId.value = p.id
|
scoringId.value = p.id
|
||||||
try {
|
try {
|
||||||
|
|
@ -47,6 +91,15 @@ async function scorePosting(p: JobPosting) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
onMounted(loadPostings)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -54,6 +107,37 @@ onMounted(loadPostings)
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h1 class="text-2xl font-bold">Research</h1>
|
<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">
|
<section class="bg-white rounded-lg border border-gray-200 p-4 flex gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="newUrl"
|
v-model="newUrl"
|
||||||
|
|
@ -73,6 +157,7 @@ onMounted(loadPostings)
|
||||||
<th class="px-3 py-2">Title</th>
|
<th class="px-3 py-2">Title</th>
|
||||||
<th class="px-3 py-2">Location</th>
|
<th class="px-3 py-2">Location</th>
|
||||||
<th class="px-3 py-2">Source</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">Fetched</th>
|
||||||
<th class="px-3 py-2">Actions</th>
|
<th class="px-3 py-2">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -83,6 +168,16 @@ onMounted(loadPostings)
|
||||||
<td class="px-3 py-2">{{ p.title }}</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.location }}</td>
|
||||||
<td class="px-3 py-2">{{ p.source }}</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 text-gray-500">{{ p.fetched_at?.slice(0, 10) }}</td>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue