diff --git a/README.md b/README.md
index a0f8129a..5e996dc7 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
# Reqcore
-**The open-source ATS built for developers. Self-hosted. No per-seat fees.**
+**The simple, open-source ATS. Self-hosted. No per-seat fees.**
[](LICENSE)
[](https://github.com/reqcore-inc/reqcore/actions/workflows/e2e-tests.yml)
@@ -19,13 +19,13 @@
---
-Most ATS software was designed for enterprise HR departments — complex procurement, per-seat licensing, no API access, no way to self-host. Reqcore is built for engineering teams who want to own their hiring stack the same way they own their infrastructure. It runs on **your** servers, scales without increasing your software bill, and every line of code is open source.
+Hiring software shouldn't be complicated or expensive. Most applicant tracking systems charge per seat, lock your data in their cloud, and overwhelm you with features you don't need. Reqcore is a lightweight, open-source ATS you can self-host in minutes. No per-seat fees, no vendor lock-in, no bloat — just a clean tool that helps you hire.
> **Early open-source release** — Reqcore is actively developed and improving every week. The foundation is solid (jobs, pipeline, applications, documents, job board), but some features are still on the roadmap. Check the [Roadmap](ROADMAP.md) for what's shipped and what's next.
## Why Reqcore?
-*Built for teams that deploy with Docker, not procurement.*
+*Simple hiring software you actually own.*
| | **Reqcore** | Greenhouse | Lever | Ashby | OpenCATS |
|---|:---:|:---:|:---:|:---:|:---:|
@@ -52,8 +52,8 @@ Most ATS software was designed for enterprise HR departments — complex procure
- **Document storage** — Upload and manage resumes and cover letters via S3-compatible storage (MinIO)
- **Multi-tenant organizations** — Isolated data per organization with role-based membership
- **Recruiter dashboard** — At-a-glance stats, pipeline breakdown, recent applications, and top active jobs
-- **Server-proxied documents** — Resumes are never exposed via public URLs; all access is authenticated and streamed
-- **API rate limiting** — Global per-IP limits on all `/api` endpoints with stricter auth/write thresholds
+- **Secure document access** — Resumes are never exposed via public URLs; all access is authenticated and streamed
+- **Built-in rate limiting** — Protection against abuse on all endpoints out of the box
## Quick Start
diff --git a/app/components/AppToasts.vue b/app/components/AppToasts.vue
new file mode 100644
index 00000000..d078b63e
--- /dev/null
+++ b/app/components/AppToasts.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ toast.title }}
+
+
+ {{ toast.message }}
+
+
+
+
+
+ {{ expandedToasts.has(toast.id) ? 'Hide details' : 'Show details' }}
+
+
+
+
+ {{ toast.details }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/components/AppTopBar.vue b/app/components/AppTopBar.vue
index 963aad89..b92b7f30 100644
--- a/app/components/AppTopBar.vue
+++ b/app/components/AppTopBar.vue
@@ -110,6 +110,7 @@ const jobTabs = computed(() => {
{ label: 'Pipeline', to: base, icon: Kanban, exact: true },
{ label: 'Table', to: `${base}/candidates`, icon: Table2, exact: true },
{ label: 'Application Form', to: `${base}/application-form`, icon: FileText, exact: true },
+ { label: 'AI Analysis', to: `${base}/ai-analysis`, icon: Sparkles, exact: true },
]
})
@@ -123,6 +124,7 @@ const mainNav = [
{ label: 'Candidates', to: '/dashboard/candidates', icon: Users, exact: false },
{ label: 'Applications', to: '/dashboard/applications', icon: FileText, exact: false },
{ label: 'Interviews', to: '/dashboard/interviews', icon: Calendar, exact: false },
+ { label: 'AI Analysis', to: '/dashboard/ai-analysis', icon: Sparkles, exact: true },
{ label: 'Settings', to: '/dashboard/settings', icon: Settings, exact: false },
]
diff --git a/app/components/CandidateDetailSidebar.vue b/app/components/CandidateDetailSidebar.vue
index 8c281e95..33e8e1be 100644
--- a/app/components/CandidateDetailSidebar.vue
+++ b/app/components/CandidateDetailSidebar.vue
@@ -2,7 +2,7 @@
import {
X, User, Calendar, Clock, Hash, MessageSquare, FileText,
ExternalLink, Mail, Phone, Upload, Download, Eye, Trash2,
- ArrowLeft, AlertTriangle,
+ ArrowLeft, AlertTriangle, Brain,
} from 'lucide-vue-next'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
@@ -17,6 +17,7 @@ const emit = defineEmits<{
}>()
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
+const toast = useToast()
// Detect if the job sub-nav bar is visible (adds 40px / 2.5rem)
const route = useRoute()
@@ -32,7 +33,7 @@ const hasSubNav = computed(() => {
// Tabs
// ─────────────────────────────────────────────
-const activeTab = ref<'overview' | 'documents' | 'responses'>('overview')
+const activeTab = ref<'overview' | 'documents' | 'responses' | 'ai_analysis'>('overview')
// ─────────────────────────────────────────────
// Fetch application detail
@@ -120,7 +121,7 @@ async function handleTransition(newStatus: string) {
emit('updated')
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to update status')
+ toast.error('Failed to update status', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
} finally {
isTransitioning.value = false
}
@@ -151,7 +152,7 @@ async function saveNotes() {
isEditingNotes.value = false
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to save notes')
+ toast.error('Failed to save notes', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
} finally {
isSavingNotes.value = false
}
@@ -242,7 +243,7 @@ async function handleDownload(docId: string) {
try {
await downloadDocument(docId)
} catch {
- alert('Failed to download document')
+ toast.error('Failed to download document')
}
}
@@ -255,7 +256,7 @@ async function handleDeleteDoc(docId: string) {
showDocDeleteConfirm.value = null
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to delete document')
+ toast.error('Failed to delete document', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
} finally {
isDeletingDoc.value = false
}
@@ -414,6 +415,16 @@ function formatInterviewDate(dateStr: string) {
>
Responses ({{ responsesCount }})
+
+
+ AI Analysis
+
@@ -857,6 +868,13 @@ function formatInterviewDate(dateStr: string) {
+
+
+
+
+
+
+
diff --git a/app/components/PipelineCard.vue b/app/components/PipelineCard.vue
index 4282c97a..476108a8 100644
--- a/app/components/PipelineCard.vue
+++ b/app/components/PipelineCard.vue
@@ -62,7 +62,13 @@ const transitionClasses: Record = {
{{ new Date(createdAt).toLocaleDateString() }}
-
+
{{ score }}pts
diff --git a/app/components/ScoreBreakdown.vue b/app/components/ScoreBreakdown.vue
new file mode 100644
index 00000000..edbe1df9
--- /dev/null
+++ b/app/components/ScoreBreakdown.vue
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
+
+
+
No AI analysis yet
+
Run AI analysis to evaluate this candidate against scoring criteria.
+
+
+
+ {{ isAnalyzing ? 'Analyzing…' : 'Run AI Analysis' }}
+
+
+
+
+
+ Loading scores…
+
+
+
+
+
+
+
+
+
+
Composite Score
+
+
+ {{ isAnalyzing ? 'Re-scoring…' : 'Re-score' }}
+
+
+
+
+
+ {{ scoreData!.latestRun?.compositeScore ?? '—' }}
+
+ / 100
+
+
+
+
+ {{ scoreData!.latestRun.provider }} · {{ scoreData!.latestRun.model }}
+ {{ new Date(scoreData!.latestRun.createdAt).toLocaleString() }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ cs.criterionName ?? cs.criterionKey }}
+
+
+ {{ cs.category }}
+
+
+
+
+
+
+ {{ cs.score }}/{{ cs.maxScore }}
+
+
+
+
+
+
+
+
+
+
+ Confidence:
+
+ {{ confidenceLabel(cs.confidence) }} ({{ cs.confidence }}%)
+
+
+
+
+
+
Evidence
+
{{ cs.evidence }}
+
+
+
+
+
+
+
+
+
+
+ Weight: {{ cs.weight }}%
+
+
+
+
+
+
+
+
+
+
+ {{ analyzeError }}
+ Dismiss
+
+
+
+
diff --git a/app/components/SettingsSidebar.vue b/app/components/SettingsSidebar.vue
index 58f07933..188c7a84 100644
--- a/app/components/SettingsSidebar.vue
+++ b/app/components/SettingsSidebar.vue
@@ -1,6 +1,6 @@
+
+
+
+
+
+
+
+
+
+
Failed to load AI analysis data.
+
Retry
+
+
+
+
+
+
+
+
+
+ No AI analysis yet
+
+
+ Configure your AI provider in Settings and set up scoring criteria on a job to start analyzing candidates.
+
+
+
+
+
+
+
+
+
+
AI Analysis
+
Overview of AI scoring runs and token usage
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatNumber(summary.totalRuns) }}
+
+
+
+
+ {{ summary.completedRuns }} completed
+
+
+
+ {{ summary.failedRuns }} failed
+
+
+
+
+
+
+
+
+
+
+
+ {{ successRate }}%
+
+
+ {{ summary.completedRuns }} of {{ summary.totalRuns }} successful
+
+
+
+
+
+
+
+
+
+
+ {{ formatNumber(summary.totalPromptTokens) }}
+
+
Input tokens sent
+
+
+
+
+
+
+
+
+
Completion Tokens
+
+
+
+
+
+ {{ formatNumber(summary.totalCompletionTokens) }}
+
+
Output tokens generated
+
+
+
+
+
+
+
+
+
+ {{ pricing.configured ? formatCost(totalCost) : '—' }}
+
+
+ Estimated from token usage
+
+ Set pricing to track costs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Usage — Last 30 Days
+
Daily run counts and token consumption
+
+
+
+
+
+
+
+
Runs per Day
+
+
+
+
+
+
+
{{ formatDate(day.date) }}
+
{{ day.count }} run{{ day.count !== 1 ? 's' : '' }}
+
{{ formatNumber(day.promptTokens + day.completionTokens) }} tokens
+
{{ formatCostPrecise(calcCost(day.promptTokens, day.completionTokens)) }}
+
+
+
+
+
+ {{ chartStartDate }}
+ {{ chartEndDate }}
+
+
+
+
+
+
Tokens per Day
+
+
+
+
+
+
+
{{ formatDate(day.date) }}
+
Prompt: {{ formatNumber(day.promptTokens) }}
+
Completion: {{ formatNumber(day.completionTokens) }}
+
{{ formatCostPrecise(calcCost(day.promptTokens, day.completionTokens)) }}
+
+
+
+
+
+
+ Prompt
+ Completion
+
+
+ {{ chartStartDate }}
+ {{ chartEndDate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Model Breakdown
+
Usage per AI provider and model
+
+
+
+
+
+
+
+
+ Provider
+ Model
+ Runs
+ Prompt
+ Completion
+ Total Tokens
+ Cost
+
+
+
+
+ {{ m.provider }}
+
+ {{ m.model }}
+
+ {{ m.runCount }}
+ {{ formatNumber(m.totalPromptTokens) }}
+ {{ formatNumber(m.totalCompletionTokens) }}
+ {{ formatNumber(m.totalTokens) }}
+ {{ formatCost(calcCost(m.totalPromptTokens, m.totalCompletionTokens)) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Recent Runs
+
Latest AI scoring activity
+
+
+
+
+
+
+
No AI analysis runs yet
+
+ Runs will appear here once you score candidates.
+
+
+
+
+
+
+
+ Status
+ Candidate
+ Job
+ Score
+ Model
+ Tokens
+ Cost
+ Date
+
+
+
+
+
+
+
+ {{ run.status }}
+
+
+
+ {{ run.candidateName }}
+
+
+ {{ run.jobTitle }}
+
+
+
+ {{ run.compositeScore }}
+
+ —
+
+
+ {{ run.model }}
+
+
+
+ {{ formatNumber((run.promptTokens ?? 0) + (run.completionTokens ?? 0)) }}
+
+ —
+
+
+ {{ formatCostPrecise(calcCost(run.promptTokens ?? 0, run.completionTokens ?? 0)) }}
+ —
+
+
+ {{ formatDateTime(run.createdAt) }}
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/dashboard/applications/[id].vue b/app/pages/dashboard/applications/[id].vue
index 1c48a7e2..208fded1 100644
--- a/app/pages/dashboard/applications/[id].vue
+++ b/app/pages/dashboard/applications/[id].vue
@@ -10,6 +10,7 @@ definePageMeta({
const route = useRoute()
const applicationId = route.params.id as string
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
+const toast = useToast()
const { application, status: fetchStatus, error, updateApplication } = useApplication(applicationId)
@@ -67,7 +68,7 @@ async function handleTransition(newStatus: string) {
await updateApplication({ status: newStatus as any })
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to update status')
+ toast.error('Failed to update status', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
} finally {
isTransitioning.value = false
}
@@ -93,7 +94,7 @@ async function saveNotes() {
isEditingNotes.value = false
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to save notes')
+ toast.error('Failed to save notes', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
} finally {
isSavingNotes.value = false
}
diff --git a/app/pages/dashboard/candidates/[id].vue b/app/pages/dashboard/candidates/[id].vue
index 6e62e5d8..2d22a021 100644
--- a/app/pages/dashboard/candidates/[id].vue
+++ b/app/pages/dashboard/candidates/[id].vue
@@ -11,6 +11,7 @@ definePageMeta({
const route = useRoute()
const candidateId = route.params.id as string
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
+const toast = useToast()
const { candidate, status: fetchStatus, error, refresh, updateCandidate, deleteCandidate } = useCandidate(candidateId)
@@ -93,7 +94,7 @@ async function handleSave() {
if (err.statusCode === 409 || err.data?.statusCode === 409) {
editErrors.value.email = message
} else {
- alert(message)
+ toast.error(message, { message, statusCode: err.statusCode ?? err.data?.statusCode })
}
} finally {
isSaving.value = false
@@ -113,7 +114,7 @@ async function handleDelete() {
await deleteCandidate()
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to delete candidate')
+ toast.error('Failed to delete candidate', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
isDeleting.value = false
showDeleteConfirm.value = false
}
@@ -243,7 +244,7 @@ async function handleDownload(docId: string) {
try {
await downloadDocument(docId)
} catch {
- alert('Failed to download document')
+ toast.error('Failed to download document')
}
}
@@ -254,7 +255,7 @@ async function handleDeleteDoc(docId: string) {
showDocDeleteConfirm.value = null
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to delete document')
+ toast.error('Failed to delete document', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
} finally {
isDeletingDoc.value = false
}
diff --git a/app/pages/dashboard/interviews/[id].vue b/app/pages/dashboard/interviews/[id].vue
index 0dfc030b..252c6867 100644
--- a/app/pages/dashboard/interviews/[id].vue
+++ b/app/pages/dashboard/interviews/[id].vue
@@ -15,6 +15,7 @@ definePageMeta({
const route = useRoute()
const interviewId = route.params.id as string
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
+const toast = useToast()
const { activeOrg } = useCurrentOrg()
const { interview, status: fetchStatus, error, updateInterview, deleteInterview, refresh } = useInterview(interviewId)
@@ -99,7 +100,7 @@ async function handleTransition(newStatus: InterviewStatus) {
await updateInterview({ status: newStatus })
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to update status')
+ toast.error('Failed to update status', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
} finally {
isTransitioning.value = false
}
@@ -153,7 +154,7 @@ async function saveNotes() {
isEditingNotes.value = false
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to save notes')
+ toast.error('Failed to save notes', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
} finally {
isSavingNotes.value = false
}
@@ -261,7 +262,7 @@ async function handleDelete() {
await navigateTo(useLocalePath()('/dashboard/interviews'))
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to delete interview')
+ toast.error('Failed to delete interview', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
} finally {
isDeleting.value = false
}
diff --git a/app/pages/dashboard/interviews/index.vue b/app/pages/dashboard/interviews/index.vue
index 2dde67b3..e9c95a1f 100644
--- a/app/pages/dashboard/interviews/index.vue
+++ b/app/pages/dashboard/interviews/index.vue
@@ -3,7 +3,7 @@ import {
Calendar, Clock, Search, X, ChevronDown, Video, Phone,
Building2, Code2, FileText, UsersRound, MoreHorizontal,
CheckCircle2, XCircle, AlertTriangle, UserRound, Briefcase,
- Plus, Pencil, Trash2, MapPin, Users, Filter, CalendarDays,
+ Pencil, Trash2, MapPin, Users, CalendarDays,
Mail, ExternalLink,
} from 'lucide-vue-next'
@@ -19,6 +19,7 @@ useSeoMeta({
})
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
+const toast = useToast()
// ─── Filters ──────────────────────────────────────────────────────
const searchInput = ref('')
@@ -250,7 +251,7 @@ async function handleDelete() {
deletingInterview.value = null
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err?.data?.statusMessage ?? 'Failed to delete interview')
+ toast.error('Failed to delete interview', { message: err?.data?.statusMessage, statusCode: err?.data?.statusCode })
} finally {
isDeleting.value = false
}
@@ -268,7 +269,7 @@ async function quickStatusChange(interviewItem: typeof interviews.value[number],
await updateInterview(interviewItem.id, { status: newStatus })
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err?.data?.statusMessage ?? 'Failed to update status')
+ toast.error('Failed to update status', { message: err?.data?.statusMessage, statusCode: err?.data?.statusCode })
}
}
@@ -300,59 +301,34 @@ const statusCounts = computed(() => {
-
+
-
-
-
-
- Interviews
-
-
- Manage all scheduled interviews across your jobs
-
-
-
-
- Email Templates
-
+
+
+
Interviews
+
+ Manage all scheduled interviews across your jobs
+
-
-
-
-
-
-
-
-
-
-
{{ statusCounts[s] }}
-
{{ statusConfig[s].label }}
-
-
+
+ Email Templates
+
-
+
-
+
{
+
+
+
+
+ {{ statusConfig[s].label }}
+ {{ statusCounts[s] }}
+
+
+
-
+
{
List
{
-
-
-
Loading interviews…
+
-
- Failed to load interviews. Please try again.
+
+ Failed to load interviews.
+ Retry
-
-
-
-
-
+
+
+
{{ searchInput || activeStatus ? 'No matching interviews' : 'No interviews yet' }}
-
-
+
+
{{ searchInput || activeStatus
? 'Try adjusting your filters.'
: 'Interviews will appear here when you schedule them from the pipeline.' }}
Clear filters
@@ -426,14 +438,14 @@ const statusCounts = computed(() => {
-
+
{
-
-
- {{ interviewItem.title }}
-
-
+
+ {{ interviewItem.title }}
+
@@ -462,36 +472,36 @@ const statusCounts = computed(() => {
-
-
+
+
{{ interviewItem.candidateFirstName }} {{ interviewItem.candidateLastName }}
-
+
{{ interviewItem.jobTitle }}
-
-
+
+
{{ formatDateShort(interviewItem.scheduledAt) }}
-
+
{{ formatTime(interviewItem.scheduledAt) }} · {{ interviewItem.duration }}min
-
+
{{ typeLabels[interviewItem.type] }}
-
+
{{ interviewItem.location }}
-
+
{{ interviewItem.interviewers.join(', ') }}
@@ -518,14 +528,13 @@ const statusCounts = computed(() => {
-
-
- Complete
-
-
+
+ Complete
+
@@ -579,25 +588,30 @@ const statusCounts = computed(() => {
+
+
+
+ {{ total }} interview{{ total === 1 ? '' : 's' }} total
+
-
-
-
+
+
+
{{ dateLabel }}
{{ dateInterviews.length }} interview{{ dateInterviews.length === 1 ? '' : 's' }}
-
+
{
{{ interviewItem.duration }}min
{{ statusConfig[interviewItem.status]?.label }}
@@ -678,6 +692,11 @@ const statusCounts = computed(() => {
+
+
+
+ {{ total }} interview{{ total === 1 ? '' : 's' }} total
+
diff --git a/app/pages/dashboard/jobs/[id]/ai-analysis.vue b/app/pages/dashboard/jobs/[id]/ai-analysis.vue
new file mode 100644
index 00000000..fa37f25f
--- /dev/null
+++ b/app/pages/dashboard/jobs/[id]/ai-analysis.vue
@@ -0,0 +1,605 @@
+
+
+
+
+
+
+ Loading…
+
+
+
+
+ {{ jobError.statusCode === 404 ? 'Job not found.' : 'Failed to load job.' }}
+ Back to Jobs
+
+
+
+
+
+
AI Analysis
+
+ Configure how AI evaluates and scores candidates for {{ job.title }} .
+
+
+
+
+
+
+
+
+
+
+
+
+ Pre-made templates
+
+ Choose from expert-designed scoring rubrics for common role types.
+
+
+
+
+
+
+
+
+
+
+ Generate from job description
+
+ AI analyzes your job description and creates tailored criteria.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Write your own
+
+ Create custom scoring criteria tailored to your exact needs.
+
+
+
+
+
+
+
+
+ {{ tmpl.label }}
+ {{ tmpl.desc }}
+
+
+
+
+
+
No scoring criteria configured yet. Choose a starting point above, or add criteria manually.
+
+
+
+
+
+
+
+ {{ scoringCriteria.length }} {{ scoringCriteria.length === 1 ? 'criterion' : 'criteria' }} configured
+
+
+
+
+ Reset
+
+
+ Clear all
+
+
+
+
+
+
+
+
+
+ {{ criterion.name }}
+
+ {{ categoryLabels[criterion.category] ?? criterion.category }}
+
+
+
+ {{ criterion.description }}
+
+
+
+
+
+
+
+
+
+ Weight
+
+
+ {{ criterion.weight }}
+
+
+
+
+ Max score: {{ criterion.maxScore }}
+ Key: {{ criterion.key }}
+
+
+
+
+
+
+
+ Add criterion
+
+
+
+
+
+
+
+ Save criteria
+
+ Unsaved changes
+
+
+
+
+
+
Add custom criterion
+
+
+ Name *
+
+
+
+ Category
+
+ {{ label }}
+
+
+
+
+ Description
+
+
+
+
+
+ Add criterion
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
+
+ Automatically score every new applicant
+
+
+ When a candidate applies, AI will automatically analyze their resume against these criteria and assign a score. Requires an AI provider configured in settings plus a resume upload.
+
+
+
+
+
+
+
diff --git a/app/pages/dashboard/jobs/[id]/application-form.vue b/app/pages/dashboard/jobs/[id]/application-form.vue
index ff98f0b0..717310bd 100644
--- a/app/pages/dashboard/jobs/[id]/application-form.vue
+++ b/app/pages/dashboard/jobs/[id]/application-form.vue
@@ -8,6 +8,7 @@ definePageMeta({
const route = useRoute()
const jobId = route.params.id as string
+const toast = useToast()
const { job, status: fetchStatus, error, updateJob } = useJob(jobId)
@@ -36,7 +37,7 @@ async function copyApplicationLink() {
setTimeout(() => { linkCopied.value = false }, 2000)
} catch {
// Fallback for non-HTTPS contexts
- alert(applicationUrl.value)
+ toast.info(applicationUrl.value)
}
}
diff --git a/app/pages/dashboard/jobs/[id]/index.vue b/app/pages/dashboard/jobs/[id]/index.vue
index 8cca4290..02d7e69f 100644
--- a/app/pages/dashboard/jobs/[id]/index.vue
+++ b/app/pages/dashboard/jobs/[id]/index.vue
@@ -5,7 +5,7 @@ import {
UserPlus, Pencil, Trash2, MoreHorizontal, Globe, ChevronDown, X,
Video, Building2, Code2, UsersRound, Save, Check, MapPin, Users, Plus,
CheckCircle2, XCircle, AlertTriangle, ArrowUpDown, ListFilter,
- Maximize2, Minimize2,
+ Maximize2, Minimize2, Brain, Loader2,
} from 'lucide-vue-next'
import { z } from 'zod'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
@@ -21,6 +21,7 @@ const localePath = useLocalePath()
const jobId = route.params.id as string
const { handlePreviewReadOnlyError } = usePreviewReadOnly()
const { track } = useTrack()
+const toast = useToast()
// ─────────────────────────────────────────────
// Job data (with update/delete support)
@@ -69,7 +70,7 @@ type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'score-d
type ScoreFilter = 'all' | 'high' | 'medium' | 'low' | 'none'
type InterviewFilter = 'all' | 'has-interview' | 'no-interview'
-const sortBy = ref
('date-desc')
+const sortBy = ref('score-desc')
const scoreFilter = ref('all')
const interviewFilter = ref('all')
const showSortPanel = ref(false)
@@ -124,6 +125,7 @@ function selectSort(option: SortOption) {
function closePanels() {
showSortPanel.value = false
showFilterPanel.value = false
+ showOverviewDropdown.value = false
}
const filteredApplications = computed(() => {
@@ -228,51 +230,49 @@ watch(focusStatus, () => {
const currentSummary = computed(() => filteredApplications.value[currentIndex.value] ?? null)
-// Detail tab for center panel (used for scroll-to-section navigation)
-const detailTab = ref<'overview' | 'interviews' | 'documents' | 'responses'>('overview')
+// Detail tab for center panel
+type DetailTab = 'overview' | 'interviews' | 'documents' | 'responses' | 'ai-analysis'
+const detailTab = ref('overview')
-// Section refs for scroll-to navigation
-const overviewRef = ref(null)
-const interviewsRef = ref(null)
-const documentsRef = ref(null)
-const responsesRef = ref(null)
-const detailScrollContainer = ref(null)
+// Overview section visibility toggles
+const overviewSections = reactive({
+ aiAnalysis: true,
+ interviews: true,
+ documents: true,
+ responses: true,
+})
+const showOverviewDropdown = ref(false)
+const overviewDropdownRef = ref(null)
-function scrollToSection(section: 'overview' | 'interviews' | 'documents' | 'responses') {
- detailTab.value = section
- const refs: Record>> = {
- overview: overviewRef,
- interviews: interviewsRef,
- documents: documentsRef,
- responses: responsesRef,
- }
- const el = refs[section]?.value
- if (el) {
- el.scrollIntoView({ behavior: 'smooth', block: 'start' })
+function handleOverviewDropdownClickOutside(event: MouseEvent) {
+ if (overviewDropdownRef.value && !overviewDropdownRef.value.contains(event.target as Node)) {
+ showOverviewDropdown.value = false
}
}
-function handleDetailScroll() {
- const container = detailScrollContainer.value
- if (!container) return
- const scrollTop = container.scrollTop
- const offset = 120 // offset to trigger slightly before section top
+watch(showOverviewDropdown, (val) => {
+ if (val) {
+ setTimeout(() => document.addEventListener('click', handleOverviewDropdownClickOutside), 0)
+ } else {
+ document.removeEventListener('click', handleOverviewDropdownClickOutside)
+ }
+})
- const sections = [
- { id: 'responses' as const, el: responsesRef.value },
- { id: 'documents' as const, el: documentsRef.value },
- { id: 'interviews' as const, el: interviewsRef.value },
- { id: 'overview' as const, el: overviewRef.value },
- ]
+// Which sections to display based on active tab
+const showSection = computed(() => ({
+ profile: detailTab.value === 'overview',
+ aiAnalysis: detailTab.value === 'overview' ? overviewSections.aiAnalysis : detailTab.value === 'ai-analysis',
+ interviews: detailTab.value === 'overview' ? overviewSections.interviews : detailTab.value === 'interviews',
+ documents: detailTab.value === 'overview' ? overviewSections.documents : detailTab.value === 'documents',
+ responses: detailTab.value === 'overview' ? overviewSections.responses : detailTab.value === 'responses',
+}))
- for (const section of sections) {
- if (section.el && section.el.offsetTop - container.offsetTop <= scrollTop + offset) {
- detailTab.value = section.id
- return
- }
- }
- detailTab.value = 'overview'
-}
+// Section refs
+const overviewRef = ref(null)
+const interviewsRef = ref(null)
+const documentsRef = ref(null)
+const responsesRef = ref(null)
+const detailScrollContainer = ref(null)
type SwipeDocument = {
id: string
@@ -699,7 +699,7 @@ async function handleInterviewTransition(interviewId: string, newStatus: Intervi
await refreshJobInterviews()
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err?.data?.statusMessage ?? 'Failed to update status')
+ toast.error('Failed to update status', { message: err?.data?.statusMessage, statusCode: err?.data?.statusCode })
} finally {
isInterviewTransitioning.value = false
}
@@ -776,7 +776,7 @@ async function changeStatus(status: string) {
}
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err?.data?.statusMessage ?? 'Failed to update status')
+ toast.error('Failed to update status', { message: err?.data?.statusMessage, statusCode: err?.data?.statusCode })
} finally {
isMutating.value = false
}
@@ -928,7 +928,7 @@ async function handleJobTransition(newStatus: string) {
await refreshJob()
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to update status')
+ toast.error('Failed to update status', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
} finally {
isJobTransitioning.value = false
}
@@ -997,7 +997,7 @@ async function handleSave() {
showEditModal.value = false
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to save changes')
+ toast.error('Failed to save changes', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
} finally {
isSaving.value = false
}
@@ -1023,7 +1023,7 @@ async function handleDelete() {
await deleteJob()
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
- alert(err.data?.statusMessage ?? 'Failed to delete job')
+ toast.error('Failed to delete job', { message: err.data?.statusMessage, statusCode: err.data?.statusCode })
isDeleting.value = false
showDeleteConfirm.value = false
}
@@ -1040,6 +1040,97 @@ function handleCandidateApplied() {
refreshApps()
}
+// ─────────────────────────────────────────────
+// Bulk AI analysis
+// ─────────────────────────────────────────────
+
+const isScoringAll = ref(false)
+const scoringProgress = ref({ done: 0, total: 0 })
+const isScoringIndividual = ref(false)
+
+async function scoreAllCandidates() {
+ isScoringAll.value = true
+ scoringProgress.value = { done: 0, total: 0 }
+ showMoreMenu.value = false
+ try {
+ const { applicationIds } = await $fetch(`/api/jobs/${jobId}/analyze-all`, {
+ method: 'POST',
+ })
+ scoringProgress.value.total = applicationIds.length
+ if (applicationIds.length === 0) {
+ toast.info('All candidates scored', 'Every candidate already has a score.')
+ return
+ }
+
+ let failed = 0
+ for (const appId of applicationIds) {
+ try {
+ await $fetch(`/api/applications/${appId}/analyze`, {
+ method: 'POST',
+ })
+ } catch {
+ failed++
+ }
+ scoringProgress.value.done++
+ }
+ await refreshApps()
+ if (failed === 0) {
+ toast.success('Scoring complete', `${applicationIds.length} candidate${applicationIds.length === 1 ? '' : 's'} scored successfully.`)
+ } else {
+ toast.warning('Scoring partially complete', `${applicationIds.length - failed} scored, ${failed} failed (missing resume or criteria).`)
+ }
+ } catch (err: any) {
+ const statusMessage = err?.data?.statusMessage ?? ''
+ if (statusMessage.includes('AI provider not configured') || statusMessage.includes('No scoring criteria')) {
+ toast.add({
+ type: 'warning',
+ title: 'Cannot score candidates',
+ message: statusMessage,
+ link: statusMessage.includes('AI provider')
+ ? { label: 'Go to AI Settings', href: '/dashboard/settings/ai' }
+ : undefined,
+ duration: 8000,
+ })
+ } else {
+ toast.error('Scoring failed', { message: statusMessage || 'An unexpected error occurred.', statusCode: err?.data?.statusCode })
+ }
+ } finally {
+ isScoringAll.value = false
+ }
+}
+
+async function scoreIndividualCandidate(applicationId: string) {
+ isScoringIndividual.value = true
+ try {
+ await $fetch(`/api/applications/${applicationId}/analyze`, {
+ method: 'POST',
+ })
+ await refreshApps()
+ // Re-fetch the detail so score updates in the detail panel
+ if (currentApplicationId.value === applicationId) {
+ await executeDetailFetch()
+ }
+ toast.success('Candidate scored', 'AI analysis complete.')
+ } catch (err: any) {
+ const statusMessage = err?.data?.statusMessage ?? ''
+ if (statusMessage.includes('AI provider not configured')) {
+ toast.add({
+ type: 'warning',
+ title: 'AI provider not configured',
+ message: 'Set up your AI provider in Settings first.',
+ link: { label: 'Go to AI Settings', href: '/dashboard/settings/ai' },
+ duration: 8000,
+ })
+ } else if (statusMessage.includes('No scoring criteria')) {
+ toast.warning('No scoring criteria', 'Add scoring criteria to this job first.')
+ } else {
+ toast.error('Scoring failed', { message: statusMessage || 'An unexpected error occurred.', statusCode: err?.data?.statusCode })
+ }
+ } finally {
+ isScoringIndividual.value = false
+ }
+}
+
// ─────────────────────────────────────────────
// More menu
// ─────────────────────────────────────────────
@@ -1063,6 +1154,7 @@ watch(showMoreMenu, (val) => {
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
+ document.removeEventListener('click', handleOverviewDropdownClickOutside)
})
const isLoading = computed(() => {
@@ -1186,6 +1278,15 @@ function closeDocPreview() {
Add Candidate
+
+
+
+ {{ isScoringAll ? `Scoring ${scoringProgress.done}/${scoringProgress.total}…` : 'Score All Candidates' }}
+
-
+
@@ -1589,6 +1690,18 @@ function closeDocPreview() {
>
{{ currentSummary.score }} pts
+
+
+
+ {{ isScoringIndividual ? 'Scoring…' : (currentSummary.score != null ? 'Re-score' : 'Score Candidate') }}
+
Applied {{ new Date(currentSummary.createdAt).toLocaleDateString() }}
@@ -1631,24 +1744,79 @@ function closeDocPreview() {
-
+
+
- Profile
+ AI Analysis
Interviews
Documents
- Responses ({{ resolvedCurrentApplication.responses.length }})
+ Responses
+
+ ({{ resolvedCurrentApplication.responses.length }})
+
@@ -1695,8 +1868,10 @@ function closeDocPreview() {
-
-