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: 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) @@ -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 @@ + + + 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 }}) + @@ -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 @@ + + + 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 @@ + + 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(() => { 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 @@ + + +