Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
[![E2E Tests](https://github.com/reqcore-inc/reqcore/actions/workflows/e2e-tests.yml/badge.svg)](https://github.com/reqcore-inc/reqcore/actions/workflows/e2e-tests.yml)
Expand All @@ -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 |
|---|:---:|:---:|:---:|:---:|:---:|
Expand All @@ -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

Expand Down
139 changes: 139 additions & 0 deletions app/components/AppToasts.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { X, AlertTriangle, CheckCircle, Info, AlertCircle, ExternalLink, ChevronDown } from 'lucide-vue-next'
import type { Toast } from '~/composables/useToast'

const { toasts, remove } = useToast()

const expandedToasts = ref(new Set<string>())

function toggleDetails(id: string) {
if (expandedToasts.value.has(id)) {
expandedToasts.value.delete(id)
} else {
expandedToasts.value.add(id)
}
}
Comment on lines +7 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Set mutations may not trigger reactivity.

Direct mutations on a Set stored in a ref (delete/add) don't trigger Vue's reactivity system. The toggle may not cause the UI to update when expanding/collapsing details.

🛠️ Proposed fix using reactive Set replacement
 function toggleDetails(id: string) {
+  const newSet = new Set(expandedToasts.value)
   if (expandedToasts.value.has(id)) {
-    expandedToasts.value.delete(id)
+    newSet.delete(id)
   } else {
-    expandedToasts.value.add(id)
+    newSet.add(id)
   }
+  expandedToasts.value = newSet
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const expandedToasts = ref(new Set<string>())
function toggleDetails(id: string) {
if (expandedToasts.value.has(id)) {
expandedToasts.value.delete(id)
} else {
expandedToasts.value.add(id)
}
}
function toggleDetails(id: string) {
const newSet = new Set(expandedToasts.value)
if (expandedToasts.value.has(id)) {
newSet.delete(id)
} else {
newSet.add(id)
}
expandedToasts.value = newSet
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/AppToasts.vue` around lines 7 - 15, expandedToasts is a ref
holding a Set and toggleDetails mutates that Set in-place (using add/delete),
which won’t reliably trigger Vue reactivity; update toggleDetails (the function
named toggleDetails and the ref expandedToasts) to replace expandedToasts.value
with a new Set each time you toggle (e.g., create a copy of
expandedToasts.value, perform add/delete on the copy, then assign the copy back
to expandedToasts.value) so Vue detects the change and the UI updates.


const typeConfig: Record<string, { icon: typeof AlertTriangle; containerClass: string; iconClass: string; accentClass: string }> = {
error: {
icon: AlertCircle,
containerClass: 'border-danger-200/60 dark:border-danger-800/60 bg-white dark:bg-surface-900 ring-1 ring-danger-100 dark:ring-danger-900/30',
iconClass: 'text-danger-500',
accentClass: 'bg-danger-500',
},
success: {
icon: CheckCircle,
containerClass: 'border-success-200/60 dark:border-success-800/60 bg-white dark:bg-surface-900 ring-1 ring-success-100 dark:ring-success-900/30',
iconClass: 'text-success-500',
accentClass: 'bg-success-500',
},
warning: {
icon: AlertTriangle,
containerClass: 'border-warning-200/60 dark:border-warning-800/60 bg-white dark:bg-surface-900 ring-1 ring-warning-100 dark:ring-warning-900/30',
iconClass: 'text-warning-500',
accentClass: 'bg-warning-500',
},
info: {
icon: Info,
containerClass: 'border-brand-200/60 dark:border-brand-800/60 bg-white dark:bg-surface-900 ring-1 ring-brand-100 dark:ring-brand-900/30',
iconClass: 'text-brand-500',
accentClass: 'bg-brand-500',
},
}

function getConfig(type: string) {
return typeConfig[type] ?? typeConfig.info!
}
</script>

<template>
<Teleport to="body">
<div
class="fixed bottom-4 right-4 z-[100] flex flex-col-reverse gap-3 max-w-md w-full pointer-events-none"
aria-live="polite"
>
<TransitionGroup
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-200 ease-in"
enter-from-class="opacity-0 translate-y-3 scale-95"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-3 scale-95"
>
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto rounded-xl border shadow-xl overflow-hidden backdrop-blur-sm"
:class="getConfig(toast.type).containerClass"
>
<!-- Accent bar -->
<div class="h-0.5" :class="getConfig(toast.type).accentClass" />

<div class="p-4">
<div class="flex items-start gap-3">
<div class="flex items-center justify-center size-8 rounded-lg bg-surface-50 dark:bg-surface-800 shrink-0">
<component
:is="getConfig(toast.type).icon"
class="size-4"
:class="getConfig(toast.type).iconClass"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-surface-900 dark:text-surface-100 leading-snug">
{{ toast.title }}
</p>
<p
v-if="toast.message"
class="mt-1 text-xs text-surface-500 dark:text-surface-400 leading-relaxed"
>
{{ toast.message }}
</p>

<!-- Expandable details toggle -->
<button
v-if="toast.details"
type="button"
class="mt-2 inline-flex items-center gap-1 text-xs font-medium text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 transition-colors"
@click="toggleDetails(toast.id)"
>
<ChevronDown
class="size-3.5 transition-transform duration-200"
:class="{ 'rotate-180': expandedToasts.has(toast.id) }"
/>
{{ expandedToasts.has(toast.id) ? 'Hide details' : 'Show details' }}
</button>

<!-- Expanded details -->
<div
v-if="toast.details && expandedToasts.has(toast.id)"
class="mt-2 rounded-lg bg-surface-50 dark:bg-surface-800/80 border border-surface-200 dark:border-surface-700 p-2.5 text-[11px] font-mono text-surface-600 dark:text-surface-400 leading-relaxed break-all max-h-40 overflow-y-auto"
>
{{ toast.details }}
</div>

<div v-if="toast.link" class="mt-2.5">
<a
:href="toast.link.href"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs font-medium text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 transition-colors"
>
{{ toast.link.label }}
<ExternalLink class="size-3" />
</a>
</div>
</div>
<button
type="button"
class="shrink-0 rounded-lg p-1 text-surface-400 hover:text-surface-600 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
@click="remove(toast.id)"
>
<X class="size-4" />
</button>
</div>
</div>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
2 changes: 2 additions & 0 deletions app/components/AppTopBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]
})

Expand All @@ -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 },
]

Expand Down
30 changes: 24 additions & 6 deletions app/components/CandidateDetailSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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')
}
}

Expand All @@ -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
}
Expand Down Expand Up @@ -414,6 +415,16 @@ function formatInterviewDate(dateStr: string) {
>
Responses ({{ responsesCount }})
</button>
<button
class="cursor-pointer px-3 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px inline-flex items-center gap-1.5"
:class="activeTab === 'ai_analysis'
? 'border-brand-600 text-brand-600'
: 'border-transparent text-surface-500 hover:text-surface-700 hover:border-surface-300 dark:hover:text-surface-300'"
@click="activeTab = 'ai_analysis'"
>
<Brain class="size-3.5" />
AI Analysis
</button>
</div>
</div>

Expand Down Expand Up @@ -857,6 +868,13 @@ function formatInterviewDate(dateStr: string) {
</div>
</div>

<!-- ═══════════════════════════════════════ -->
<!-- AI ANALYSIS TAB -->
<!-- ═══════════════════════════════════════ -->
<div v-if="activeTab === 'ai_analysis'">
<ScoreBreakdown :application-id="props.applicationId" @scored="refresh(); emit('updated')" />
</div>

</template>
</div>
</aside>
Expand Down
8 changes: 7 additions & 1 deletion app/components/PipelineCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ const transitionClasses: Record<string, string> = {
<Calendar class="size-3" />
{{ new Date(createdAt).toLocaleDateString() }}
</span>
<span v-if="score != null" class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold ring-1 ring-inset bg-surface-50 text-surface-600 ring-surface-200 dark:bg-surface-800/60 dark:text-surface-300 dark:ring-surface-700">
<span v-if="score != null" class="inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-semibold ring-1 ring-inset"
:class="score >= 75
? 'bg-success-50 text-success-700 ring-success-200 dark:bg-success-950 dark:text-success-300 dark:ring-success-800'
: score >= 40
? 'bg-warning-50 text-warning-700 ring-warning-200 dark:bg-warning-950 dark:text-warning-300 dark:ring-warning-800'
: 'bg-danger-50 text-danger-700 ring-danger-200 dark:bg-danger-950 dark:text-danger-300 dark:ring-danger-800'"
>
{{ score }}pts
</span>
</div>
Expand Down
Loading
Loading