-
-
-
- All jobs
-
-
- {{ otherJobs.length }} job{{ otherJobs.length === 1 ? '' : 's' }}
-
-
-
-
-
-
-
-
- {{ j.title }}
-
-
- {{ j.status }}
-
-
-
-
{{ typeLabels[j.type] ?? j.type }}
-
-
- {{ j.location }}
-
-
- {{ totalActive(j.pipeline) }} active candidate{{ totalActive(j.pipeline) === 1 ? '' : 's' }}
-
-
- Not published yet
-
+
+
+
-
-
-
+
+
+
+ Create new job
+
+
+
+
+
+ Add candidate
+
+
+
+
+
+ Review applications
+
+
+
+
+
+ View interviews
+
-
-
-
-
+
-
-
-
- {{ total }} job{{ total === 1 ? '' : 's' }} total
-
diff --git a/app/pages/dashboard/interviews/[id].vue b/app/pages/dashboard/interviews/[id].vue
new file mode 100644
index 00000000..0dfc030b
--- /dev/null
+++ b/app/pages/dashboard/interviews/[id].vue
@@ -0,0 +1,1022 @@
+
+
+
+
+
+
+
+ Back to Interviews
+
+
+
+
+
+
Loading interviewβ¦
+
+
+
+
+ {{ (error as any).statusCode === 404 ? 'Interview not found.' : 'Failed to load interview.' }}
+ Back to Interviews
+
+
+
+
+
+
+
+
+
+ {{ getCandidateInitials(interview.candidateFirstName, interview.candidateLastName) }}
+
+
+
+
+ {{ interview.title }}
+
+
+
+ {{ statusConfig[interview.status as InterviewStatus]?.label }}
+
+
+
+
+
+ {{ interview.candidateFirstName }} {{ interview.candidateLastName }}
+
+
+
+
+ {{ interview.jobTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Invitation sent {{ formatDate(interview.invitationSentAt) }}
+
+
+
+
+
Open in Google Calendar
+
Synced to Google Calendar
+
+
+
+
+
+
+ Quick actions
+
+
+ {{ nextStatus === 'scheduled' ? 'Re-schedule' : `Mark ${statusConfig[nextStatus]?.label}` }}
+
+
+
+ Reschedule
+
+
+
+ {{ interview.invitationSentAt ? 'Resend Invitation' : 'Send Invitation' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Invitation Sent!
+
Email sent to {{ interview.candidateEmail }}
+
+
+
+
+
+
+
+
+
+
+
+
Send Interview Invitation
+
to {{ interview.candidateEmail }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ sendEmailError }}
+
+
+
+
+
+
Choose a Template
+
+ Manage Templates
+
+
+
+
+
+
+
+ {{ t.name }}
+
+ Built-in
+
+
+ {{ t.subject }}
+
+
+
+
+
+
+
+ {{ showEmailPreview ? 'Hide Preview' : 'Preview Email' }}
+
+
+
+
+
Subject
+
{{ emailPreviewSubject }}
+
+
+
Body
+
{{ emailPreviewBody }}
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ {{ isSendingEmail ? 'Sendingβ¦' : 'Send Invitation' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Schedule
+
+
+
+
Date & Time
+
+ {{ formatDateTime(interview.scheduledAt) }}
+
+
+
+
Duration
+ {{ interview.duration }} minutes
+
+
+
Type
+
+
+ {{ typeLabels[interview.type] ?? interview.type }}
+
+
+
+
Location / Link
+ {{ interview.location }}
+
+
+
+
+
+
+
+
+
+
+
Candidate
+
+
+ View Profile
+
+
+
+
+
+
Name
+
+ {{ interview.candidateFirstName }} {{ interview.candidateLastName }}
+
+
+
+
Email
+ {{ interview.candidateEmail }}
+
+
+
Phone
+ {{ interview.candidatePhone }}
+
+
+
Job
+
+
+ {{ interview.jobTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
Interviewers
+
+
+
+
+ {{ interviewer }}
+
+
+
+
+
+
+
+
+
Created
+ {{ formatDate(interview.createdAt) }}
+
+
+
Updated
+ {{ formatDate(interview.updatedAt) }}
+
+
+
+
+
+
+
+
+
+
+
Notes
+
+
+ {{ interview.notes ? 'Edit' : 'Add Notes' }}
+
+
+
+
+
+
+
+ {{ isSavingNotes ? 'Savingβ¦' : 'Save' }}
+
+
+ Cancel
+
+
+
+
+
+ {{ interview.notes }}
+
+
No notes yet.
+
+
+
+
+
Danger Zone
+
Permanently delete this interview. This action cannot be undone.
+
+ Delete Interview
+
+
+
+
+
+
+
+
+
+
Reschedule Interview
+
+
+ {{ rescheduleError }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Edit Interview Details
+
+
+ {{ editErrors.submit }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Delete Interview
+
+ Are you sure you want to delete {{ interview?.title }} ? This action cannot be undone.
+
+
+
+ Cancel
+
+
+ {{ isDeleting ? 'Deletingβ¦' : 'Delete' }}
+
+
+
+
+
+
+
+
diff --git a/app/pages/dashboard/interviews/index.vue b/app/pages/dashboard/interviews/index.vue
new file mode 100644
index 00000000..2dde67b3
--- /dev/null
+++ b/app/pages/dashboard/interviews/index.vue
@@ -0,0 +1,844 @@
+
+
+
+
+
+
+
+
+
+ Interviews
+
+
+ Manage all scheduled interviews across your jobs
+
+
+
+
+ Email Templates
+
+
+
+
+
+
+
+
+
+
+
+
{{ statusCounts[s] }}
+
{{ statusConfig[s].label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ List
+
+
+
+ Timeline
+
+
+
+
+
+
+
+
Loading interviewsβ¦
+
+
+
+
+ Failed to load interviews. Please try again.
+
+
+
+
+
+
+
+
+ {{ 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
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getCandidateInitials(interviewItem.candidateFirstName, interviewItem.candidateLastName) }}
+
+
+
+
+
+
+
+ {{ interviewItem.title }}
+
+
+
+
+ {{ statusConfig[interviewItem.status]?.label }}
+
+
+
+
+
+
+
+ {{ interviewItem.candidateFirstName }} {{ interviewItem.candidateLastName }}
+
+
+
+ {{ interviewItem.jobTitle }}
+
+
+
+
+
+
+
+ {{ formatDateShort(interviewItem.scheduledAt) }}
+
+
+
+ {{ formatTime(interviewItem.scheduledAt) }} Β· {{ interviewItem.duration }}min
+
+
+
+ {{ typeLabels[interviewItem.type] }}
+
+
+
+ {{ interviewItem.location }}
+
+
+
+ {{ interviewItem.interviewers.join(', ') }}
+
+
+
+ Google Calendar
+
+
+
+
+ Google Calendar
+
+
+
+
+
+
+
+
+
+
+ Complete
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit
+
+
+
+
+ Mark as {{ statusConfig[nextStatus]?.label }}
+
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ dateLabel }}
+
{{ dateInterviews.length }} interview{{ dateInterviews.length === 1 ? '' : 's' }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatTime(interviewItem.scheduledAt) }}
+
+ {{ interviewItem.duration }}min
+
+ {{ statusConfig[interviewItem.status]?.label }}
+
+
+
+
+ {{ interviewItem.title }}
+
+
+
+
+
+ {{ interviewItem.candidateFirstName }} {{ interviewItem.candidateLastName }}
+
+
+
+ {{ typeLabels[interviewItem.type] }}
+
+
+
+ {{ interviewItem.location }}
+
+
+
+ Synced
+
+
+
+
+ Synced
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Edit Interview
+
+
+ {{ editErrors.submit }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Delete Interview
+
+ Are you sure you want to delete {{ deletingInterview?.title }} ? This action cannot be undone.
+
+
+
+ Cancel
+
+
+ {{ isDeleting ? 'Deletingβ¦' : 'Delete' }}
+
+
+
+
+
+
+
diff --git a/app/pages/dashboard/interviews/templates/[id].vue b/app/pages/dashboard/interviews/templates/[id].vue
new file mode 100644
index 00000000..77a234ee
--- /dev/null
+++ b/app/pages/dashboard/interviews/templates/[id].vue
@@ -0,0 +1,377 @@
+
+
+
+
+
+
+
+ All Templates
+
+
+
+
+
Template not found
+
This template may have been deleted or doesn't exist.
+
+ Back to Templates
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ form.name }}
+
+
+
+ Built-in
+
+
+
+ {{ systemTemplate.description }}
+
+
+
+
+
+
+
+
+ {{ showPreview ? 'Hide Preview' : 'Preview' }}
+
+
+
+ {{ isDuplicating ? 'Duplicatingβ¦' : 'Duplicate as Custom' }}
+
+
+
+
+ {{ isSaving ? 'Savingβ¦' : saveSuccess ? 'Saved!' : 'Save Changes' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Template Name
+
+
+
+
+
+
+
+ Subject Line
+
+
+
+
+
+
+
+ Email Body
+
+
+
+
+
+
+
Danger Zone
+
Permanently delete this template. This action cannot be undone.
+
+
+ {{ isDeleting ? 'Deletingβ¦' : 'Delete Template' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Subject
+
{{ previewSubject }}
+
+
+
Body
+
{{ previewBody }}
+
+
+
+
+ Preview uses sample data. Actual values are populated when sending.
+
+
+
+
+
+
+
+
+ Available Variables
+
+
+ Use these placeholders in your subject and body. They'll be replaced with real data when the email is sent.
+
+
+
+ {{ v.key }}
+ {{ v.desc }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/pages/dashboard/interviews/templates/index.vue b/app/pages/dashboard/interviews/templates/index.vue
new file mode 100644
index 00000000..ae922204
--- /dev/null
+++ b/app/pages/dashboard/interviews/templates/index.vue
@@ -0,0 +1,247 @@
+
+
+
+
+
+
+
+ Back to Interviews
+
+
+
+
+
+
+
+
+
+
+ Email Templates
+
+
+
+ Manage reusable email templates for interview invitations. Use built-in templates or create your own with dynamic variables.
+
+
+
+
+ New Template
+
+
+
+
+
+
+
+
+ Built-in Templates
+
+
+
+
+
+
+ {{ t.name }}
+
+
+ {{ t.description }}
+
+
+ {{ t.subject }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Your Templates
+
+
+ {{ templates.length }}
+
+
+
+
+
+
+
+
Loading templatesβ¦
+
+
+
+
+
+
+
+
+ No custom templates yet
+
+
+ Create your own email templates to match your organization's voice and branding.
+
+
+
+ Create Your First Template
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t.name }}
+
+
+ {{ t.subject }}
+
+
+ Updated {{ new Date(t.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Delete Template
+
+ Are you sure you want to delete {{ templateToDelete?.name }} ? This cannot be undone.
+
+
+
+ Cancel
+
+
+ {{ deletingId ? 'Deletingβ¦' : 'Delete' }}
+
+
+
+
+
+
+
+
diff --git a/app/pages/dashboard/interviews/templates/new.vue b/app/pages/dashboard/interviews/templates/new.vue
new file mode 100644
index 00000000..bdf66272
--- /dev/null
+++ b/app/pages/dashboard/interviews/templates/new.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+ All Templates
+
+
+
+
+
+
+
+
+
+
+ New Template
+
+
+ Create a reusable email template for interview invitations.
+
+
+
+
+
+
+ {{ showPreview ? 'Hide Preview' : 'Preview' }}
+
+
+
+ {{ isSaving ? 'Creatingβ¦' : 'Create Template' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Template Name
+
+
+
+
+
+
+
+ Subject Line
+
+
+
+
+
+
+
+ Email Body
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Subject
+
+ {{ previewSubject || 'Enter a subject lineβ¦' }}
+
+
+
+
Body
+
+ {{ previewBody || 'Start writing to see a previewβ¦' }}
+
+
+
+
+
+ Preview uses sample data. Actual values are populated when sending.
+
+
+
+
+
+
+
+
+ Available Variables
+
+
+ Use these placeholders in your subject and body. They'll be replaced with real data when the email is sent.
+
+
+
+ {{ v.key }}
+ {{ v.desc }}
+
+
+
+
+
+
+
diff --git a/app/pages/dashboard/jobs/[id]/candidates.vue b/app/pages/dashboard/jobs/[id]/candidates.vue
index 50d8e62c..5e29580e 100644
--- a/app/pages/dashboard/jobs/[id]/candidates.vue
+++ b/app/pages/dashboard/jobs/[id]/candidates.vue
@@ -498,7 +498,11 @@ const isLoading = computed(() => jobFetchStatus.value === 'pending' || appFetchS
- {{ app.candidateEmail }}
+ {{ app.candidateEmail }}
import {
- ArrowLeft, ArrowRight, Briefcase, Clock, Hash, UserRound, Mail, MessageSquare,
+ ArrowLeft, ArrowRight, Briefcase, Calendar, Clock, Hash, UserRound, Mail, MessageSquare,
FileText, Paperclip, Download, Eye, Phone, Search, ExternalLink,
UserPlus, Pencil, Trash2, MoreHorizontal, Globe, ChevronDown, X,
+ Video, Building2, Code2, UsersRound, Save, Check, MapPin, Users, Plus,
+ CheckCircle2, XCircle, AlertTriangle, ArrowUpDown, ListFilter,
+ Maximize2, Minimize2,
} from 'lucide-vue-next'
import { z } from 'zod'
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
-import { APPLICATION_STATUS_TRANSITIONS } from '~~/shared/status-transitions'
-import { JOB_STATUS_TRANSITIONS } from '~~/shared/status-transitions'
+import { APPLICATION_STATUS_TRANSITIONS, JOB_STATUS_TRANSITIONS, INTERVIEW_STATUS_TRANSITIONS } from '~~/shared/status-transitions'
definePageMeta({
layout: 'dashboard',
@@ -44,7 +46,12 @@ const PIPELINE_STATUSES = ['new', 'screening', 'interview', 'offer', 'hired', 'r
type PipelineStatus = typeof PIPELINE_STATUSES[number]
const applications = computed(() => appData.value?.data ?? [])
-const focusStatus = ref('new')
+
+// Read initial pipeline stage from URL query param (?stage=screening)
+const initialStage = PIPELINE_STATUSES.includes(route.query.stage as any)
+ ? (route.query.stage as PipelineStatus)
+ : 'new'
+const focusStatus = ref(initialStage)
const focusedApplications = computed(() =>
applications.value.filter((application) => application.status === focusStatus.value),
@@ -52,13 +59,132 @@ const focusedApplications = computed(() =>
// Search within the focused list
const searchTerm = ref('')
+
+// βββββββββββββββββββββββββββββββββββββββββββββ
+// Filters & Sorting
+// βββββββββββββββββββββββββββββββββββββββββββββ
+
+type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'score-desc' | 'score-asc' | 'updated-desc'
+type ScoreFilter = 'all' | 'high' | 'medium' | 'low' | 'none'
+type InterviewFilter = 'all' | 'has-interview' | 'no-interview'
+
+const sortBy = ref('date-desc')
+const scoreFilter = ref('all')
+const interviewFilter = ref('all')
+const showSortPanel = ref(false)
+const showFilterPanel = ref(false)
+
+const hasActiveFilters = computed(() => scoreFilter.value !== 'all' || interviewFilter.value !== 'all')
+const activeFilterCount = computed(() => {
+ let count = 0
+ if (scoreFilter.value !== 'all') count++
+ if (interviewFilter.value !== 'all') count++
+ return count
+})
+
+function clearFilters() {
+ scoreFilter.value = 'all'
+ interviewFilter.value = 'all'
+}
+
+const sortOptions: { value: SortOption; label: string }[] = [
+ { value: 'date-desc', label: 'Newest first' },
+ { value: 'date-asc', label: 'Oldest first' },
+ { value: 'name-asc', label: 'Name A \u2192 Z' },
+ { value: 'name-desc', label: 'Name Z \u2192 A' },
+ { value: 'score-desc', label: 'Highest score' },
+ { value: 'score-asc', label: 'Lowest score' },
+ { value: 'updated-desc', label: 'Recently updated' },
+]
+
+const currentSortLabel = computed(() =>
+ sortOptions.find(o => o.value === sortBy.value)?.label ?? 'Sort',
+)
+
+const scoreFilterOptions: { value: ScoreFilter; label: string }[] = [
+ { value: 'all', label: 'All' },
+ { value: 'high', label: '75+' },
+ { value: 'medium', label: '40\u201374' },
+ { value: 'low', label: '< 40' },
+ { value: 'none', label: 'No score' },
+]
+
+const interviewFilterOptions: { value: InterviewFilter; label: string }[] = [
+ { value: 'all', label: 'All' },
+ { value: 'has-interview', label: 'Scheduled' },
+ { value: 'no-interview', label: 'None' },
+]
+
+function selectSort(option: SortOption) {
+ sortBy.value = option
+ showSortPanel.value = false
+}
+
+function closePanels() {
+ showSortPanel.value = false
+ showFilterPanel.value = false
+}
+
const filteredApplications = computed(() => {
- if (!searchTerm.value.trim()) return focusedApplications.value
- const term = searchTerm.value.toLowerCase()
- return focusedApplications.value.filter((app) => {
- const name = `${app.candidateFirstName} ${app.candidateLastName}`.toLowerCase()
- const email = (app.candidateEmail ?? '').toLowerCase()
- return name.includes(term) || email.includes(term)
+ let result = focusedApplications.value
+
+ // Text search
+ if (searchTerm.value.trim()) {
+ const term = searchTerm.value.toLowerCase()
+ result = result.filter((app) => {
+ const name = `${app.candidateFirstName} ${app.candidateLastName}`.toLowerCase()
+ const email = (app.candidateEmail ?? '').toLowerCase()
+ return name.includes(term) || email.includes(term)
+ })
+ }
+
+ // Score filter
+ if (scoreFilter.value !== 'all') {
+ result = result.filter((app) => {
+ switch (scoreFilter.value) {
+ case 'high': return app.score != null && app.score >= 75
+ case 'medium': return app.score != null && app.score >= 40 && app.score < 75
+ case 'low': return app.score != null && app.score < 40
+ case 'none': return app.score == null
+ default: return true
+ }
+ })
+ }
+
+ // Interview filter
+ if (interviewFilter.value !== 'all') {
+ result = result.filter((app) => {
+ const hasInterview = applicationsWithInterviews.value.has(app.id)
+ return interviewFilter.value === 'has-interview' ? hasInterview : !hasInterview
+ })
+ }
+
+ // Sorting
+ return [...result].sort((a, b) => {
+ switch (sortBy.value) {
+ case 'date-desc':
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+ case 'date-asc':
+ return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
+ case 'name-asc': {
+ const nameA = `${a.candidateFirstName} ${a.candidateLastName}`.toLowerCase()
+ const nameB = `${b.candidateFirstName} ${b.candidateLastName}`.toLowerCase()
+ return nameA.localeCompare(nameB)
+ }
+ case 'name-desc': {
+ const nameA = `${a.candidateFirstName} ${a.candidateLastName}`.toLowerCase()
+ const nameB = `${b.candidateFirstName} ${b.candidateLastName}`.toLowerCase()
+ return nameB.localeCompare(nameA)
+ }
+ case 'score-desc':
+ return (b.score ?? -1) - (a.score ?? -1)
+ case 'score-asc':
+ return (a.score ?? -1) - (b.score ?? -1)
+ case 'updated-desc':
+ return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
+ default:
+ return 0
+ }
})
})
@@ -96,23 +222,26 @@ watch(focusedApplications, () => {
watch(focusStatus, () => {
currentIndex.value = 0
searchTerm.value = ''
+ closePanels()
})
const currentSummary = computed(() => filteredApplications.value[currentIndex.value] ?? null)
// Detail tab for center panel (used for scroll-to-section navigation)
-const detailTab = ref<'overview' | 'documents' | 'responses'>('overview')
+const detailTab = ref<'overview' | 'interviews' | 'documents' | 'responses'>('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)
-function scrollToSection(section: 'overview' | 'documents' | 'responses') {
+function scrollToSection(section: 'overview' | 'interviews' | 'documents' | 'responses') {
detailTab.value = section
const refs: Record>> = {
overview: overviewRef,
+ interviews: interviewsRef,
documents: documentsRef,
responses: responsesRef,
}
@@ -131,6 +260,7 @@ function handleDetailScroll() {
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 },
]
@@ -200,9 +330,21 @@ const {
},
)
+// Cache the last successfully loaded detail so switching candidates doesn't flash a loading spinner
+const cachedApplication = ref(null)
+
const resolvedCurrentApplication = computed(() => {
- if (!currentApplication.value) return null
- return currentApplication.value.id === currentApplicationId.value ? currentApplication.value : null
+ if (currentApplication.value && currentApplication.value.id === currentApplicationId.value) {
+ return currentApplication.value
+ }
+ // Show cached (previous) data while the new detail is loading
+ return cachedApplication.value
+})
+
+watch(currentApplication, (val) => {
+ if (val && val.id === currentApplicationId.value) {
+ cachedApplication.value = val
+ }
})
watch(currentApplicationId, async (id) => {
@@ -316,12 +458,297 @@ function selectCandidate(index: number) {
const isMutating = ref(false)
+// βββββββββββββββββββββββββββββββββββββββββββββ
+// Interview scheduling sidebar
+// βββββββββββββββββββββββββββββββββββββββββββββ
+
+const showInterviewSidebar = ref(false)
+const interviewTargetApplication = ref<{ id: string; name: string } | null>(null)
+
+function openInterviewScheduler() {
+ if (!currentSummary.value) return
+ interviewTargetApplication.value = {
+ id: currentSummary.value.id,
+ name: `${currentSummary.value.candidateFirstName} ${currentSummary.value.candidateLastName}`,
+ }
+ showInterviewSidebar.value = true
+}
+
+async function handleInterviewScheduled() {
+ showInterviewSidebar.value = false
+ const scheduledApplicationId = interviewTargetApplication.value?.id ?? currentSummary.value?.id
+ interviewTargetApplication.value = null
+
+ // Refresh the interviews list
+ await refreshJobInterviews()
+
+ // Transition the application status to 'interview' after scheduling
+ if (currentSummary.value && currentSummary.value.status !== 'interview') {
+ const allowed = APPLICATION_STATUS_TRANSITIONS[currentSummary.value.status] ?? []
+ if (allowed.includes('interview')) {
+ await changeStatus('interview')
+
+ // Follow the candidate to the interview column so the user sees the scheduled interview
+ if (scheduledApplicationId) {
+ focusStatus.value = 'interview'
+ await nextTick()
+ const idx = filteredApplications.value.findIndex(a => a.id === scheduledApplicationId)
+ if (idx !== -1) currentIndex.value = idx
+ }
+ }
+ }
+}
+
+// βββββββββββββββββββββββββββββββββββββββββββββ
+// Interviews for this job
+// βββββββββββββββββββββββββββββββββββββββββββββ
+
+const { data: jobInterviewsData, refresh: refreshJobInterviews } = useFetch<{ data: Interview[] }>('/api/interviews', {
+ key: `pipeline-job-interviews-${jobId}`,
+ query: { jobId, limit: 100 },
+ headers: useRequestHeaders(['cookie']),
+})
+
+const jobInterviews = computed(() => jobInterviewsData.value?.data ?? [])
+
+const currentApplicationInterviews = computed(() =>
+ jobInterviews.value.filter(i => i.applicationId === currentApplicationId.value),
+)
+
+const applicationsWithInterviews = computed(() =>
+ new Set(jobInterviews.value.map(i => i.applicationId)),
+)
+
+const interviewTypeIcons: Record = {
+ video: Video,
+ phone: Phone,
+ in_person: Building2,
+ technical: Code2,
+ panel: UsersRound,
+ take_home: FileText,
+}
+
+const interviewTypeLabels: Record = {
+ video: 'Video',
+ phone: 'Phone',
+ in_person: 'In Person',
+ technical: 'Technical',
+ panel: 'Panel',
+ take_home: 'Take Home',
+}
+
+const interviewStatusClasses: Record = {
+ scheduled: 'bg-brand-50 text-brand-700 ring-brand-200 dark:bg-brand-950/50 dark:text-brand-300 dark:ring-brand-800',
+ completed: 'bg-success-50 text-success-700 ring-success-200 dark:bg-success-950/50 dark:text-success-300 dark:ring-success-800',
+ cancelled: 'bg-surface-100 text-surface-500 ring-surface-200 dark:bg-surface-800/50 dark:text-surface-400 dark:ring-surface-700',
+ no_show: 'bg-danger-50 text-danger-700 ring-danger-200 dark:bg-danger-950/50 dark:text-danger-300 dark:ring-danger-800',
+}
+
+function formatInterviewDateTime(dateStr: string) {
+ const d = new Date(dateStr)
+ return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
+ + ' at '
+ + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
+}
+
+function formatInterviewDateTimeFull(dateStr: string) {
+ return new Date(dateStr).toLocaleString('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ })
+}
+
+function isInterviewUpcoming(dateStr: string) {
+ return new Date(dateStr) > new Date()
+}
+
+// βββββββββββββββββββββββββββββββββββββββββββββ
+// Interview inline editing
+// βββββββββββββββββββββββββββββββββββββββββββββ
+
+type InterviewStatus = 'scheduled' | 'completed' | 'cancelled' | 'no_show'
+
+function getAllowedInterviewTransitions(status: string): InterviewStatus[] {
+ return (INTERVIEW_STATUS_TRANSITIONS[status] ?? []) as InterviewStatus[]
+}
+
+const interviewTransitionClasses: Record = {
+ scheduled: 'border border-surface-300 dark:border-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800',
+ completed: 'bg-success-600 text-white hover:bg-success-700',
+ cancelled: 'bg-surface-500 text-white hover:bg-surface-600',
+ no_show: 'bg-danger-600 text-white hover:bg-danger-700',
+}
+
+const interviewTransitionLabels: Record = {
+ scheduled: 'Re-schedule',
+ completed: 'Completed',
+ cancelled: 'Cancel',
+ no_show: 'No Show',
+}
+
+const interviewStatusIcons: Record = {
+ scheduled: Calendar,
+ completed: CheckCircle2,
+ cancelled: XCircle,
+ no_show: AlertTriangle,
+}
+
+const expandedInterviewId = ref(null)
+const editingInterviewId = ref(null)
+const interviewEditForm = reactive({
+ title: '',
+ type: 'video' as string,
+ location: '',
+ notes: '',
+ interviewers: [''] as string[],
+})
+const interviewEditErrors = ref>({})
+const isInterviewSaving = ref(false)
+const isInterviewTransitioning = ref(false)
+
+// Reschedule state
+const rescheduleInterviewId = ref(null)
+const rescheduleForm = reactive({
+ date: '',
+ time: '',
+ duration: 60,
+})
+const isRescheduling = ref(false)
+const rescheduleError = ref('')
+
+function toggleInterviewExpand(id: string) {
+ if (expandedInterviewId.value === id) {
+ expandedInterviewId.value = null
+ editingInterviewId.value = null
+ rescheduleInterviewId.value = null
+ } else {
+ expandedInterviewId.value = id
+ editingInterviewId.value = null
+ rescheduleInterviewId.value = null
+ }
+}
+
+function startInterviewEdit(iv: Interview) {
+ editingInterviewId.value = iv.id
+ interviewEditForm.title = iv.title
+ interviewEditForm.type = iv.type
+ interviewEditForm.location = iv.location ?? ''
+ interviewEditForm.notes = iv.notes ?? ''
+ interviewEditForm.interviewers = iv.interviewers?.length ? [...iv.interviewers] : ['']
+ interviewEditErrors.value = {}
+}
+
+function cancelInterviewEdit() {
+ editingInterviewId.value = null
+ interviewEditErrors.value = {}
+}
+
+function addEditInterviewer() {
+ interviewEditForm.interviewers.push('')
+}
+
+function removeEditInterviewer(idx: number) {
+ interviewEditForm.interviewers.splice(idx, 1)
+}
+
+async function saveInterviewEdit() {
+ interviewEditErrors.value = {}
+ if (!interviewEditForm.title.trim()) {
+ interviewEditErrors.value.title = 'Title is required'
+ return
+ }
+
+ isInterviewSaving.value = true
+ try {
+ const filteredInterviewers = interviewEditForm.interviewers.filter(i => i.trim())
+ await $fetch(`/api/interviews/${editingInterviewId.value}`, {
+ method: 'PATCH',
+ body: {
+ title: interviewEditForm.title.trim(),
+ type: interviewEditForm.type,
+ location: interviewEditForm.location.trim() || null,
+ notes: interviewEditForm.notes.trim() || null,
+ interviewers: filteredInterviewers.length > 0 ? filteredInterviewers : null,
+ },
+ })
+ editingInterviewId.value = null
+ await refreshJobInterviews()
+ } catch (err: any) {
+ if (handlePreviewReadOnlyError(err)) return
+ interviewEditErrors.value.submit = err?.data?.statusMessage ?? 'Failed to save changes'
+ } finally {
+ isInterviewSaving.value = false
+ }
+}
+
+async function handleInterviewTransition(interviewId: string, newStatus: InterviewStatus) {
+ isInterviewTransitioning.value = true
+ try {
+ await $fetch(`/api/interviews/${interviewId}`, {
+ method: 'PATCH',
+ body: { status: newStatus },
+ })
+ await refreshJobInterviews()
+ } catch (err: any) {
+ if (handlePreviewReadOnlyError(err)) return
+ alert(err?.data?.statusMessage ?? 'Failed to update status')
+ } finally {
+ isInterviewTransitioning.value = false
+ }
+}
+
+function openReschedule(iv: Interview) {
+ rescheduleInterviewId.value = iv.id
+ const d = new Date(iv.scheduledAt)
+ rescheduleForm.date = d.toISOString().slice(0, 10)
+ rescheduleForm.time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
+ rescheduleForm.duration = iv.duration
+ rescheduleError.value = ''
+}
+
+function cancelReschedule() {
+ rescheduleInterviewId.value = null
+ rescheduleError.value = ''
+}
+
+async function handleReschedule() {
+ rescheduleError.value = ''
+ if (!rescheduleForm.date || !rescheduleForm.time) {
+ rescheduleError.value = 'Date and time are required'
+ return
+ }
+
+ isRescheduling.value = true
+ try {
+ const scheduledAt = new Date(`${rescheduleForm.date}T${rescheduleForm.time}`).toISOString()
+ await $fetch(`/api/interviews/${rescheduleInterviewId.value}`, {
+ method: 'PATCH',
+ body: {
+ scheduledAt,
+ duration: rescheduleForm.duration,
+ status: 'scheduled',
+ },
+ })
+ rescheduleInterviewId.value = null
+ await refreshJobInterviews()
+ } catch (err: any) {
+ if (handlePreviewReadOnlyError(err)) return
+ rescheduleError.value = err?.data?.statusMessage ?? 'Failed to reschedule'
+ } finally {
+ isRescheduling.value = false
+ }
+}
+
async function changeStatus(status: string) {
if (!currentSummary.value || isMutating.value) return
const applicationId = currentSummary.value.id
isMutating.value = true
- const nextIndex = Math.min(currentIndex.value + 1, Math.max(filteredApplications.value.length - 1, 0))
try {
await $fetch(`/api/applications/${applicationId}`, {
@@ -331,8 +758,13 @@ async function changeStatus(status: string) {
await refreshApps()
- if (filteredApplications.value.length > 1) {
- currentIndex.value = Math.min(nextIndex, filteredApplications.value.length - 1)
+ // After the moved candidate disappears from the list, the items that came after
+ // it shift up by one index. currentIndex now naturally points to the next
+ // candidate β no change needed. We only clamp if currentIndex is now out of
+ // bounds (i.e. the moved candidate was the last item in the filtered list).
+ const newLen = filteredApplications.value.length
+ if (newLen > 0 && currentIndex.value >= newLen) {
+ currentIndex.value = newLen - 1
}
} catch (err: any) {
if (handlePreviewReadOnlyError(err)) return
@@ -352,6 +784,50 @@ function goToNextCard() {
currentIndex.value += 1
}
+// βββββββββββββββββββββββββββββββββββββββββββββ
+// Fullscreen (focus) mode
+// βββββββββββββββββββββββββββββββββββββββββββββ
+const isFullscreen = ref(false)
+const pipelineContainer = useTemplateRef('pipelineContainer')
+const teleportTarget = computed(() => isFullscreen.value && pipelineContainer.value ? pipelineContainer.value : 'body')
+
+async function toggleFullscreen() {
+ if (!isFullscreen.value) {
+ isFullscreen.value = true
+ await nextTick()
+ pipelineContainer.value?.requestFullscreen?.()
+ }
+ else {
+ isFullscreen.value = false
+ if (document.fullscreenElement) {
+ document.exitFullscreen?.()
+ }
+ }
+}
+
+function onFullscreenChange() {
+ if (!document.fullscreenElement) {
+ isFullscreen.value = false
+ }
+}
+
+onMounted(() => document.addEventListener('fullscreenchange', onFullscreenChange))
+onBeforeUnmount(() => document.removeEventListener('fullscreenchange', onFullscreenChange))
+
+function goToPreviousStage() {
+ const idx = PIPELINE_STATUSES.indexOf(focusStatus.value)
+ if (idx > 0) {
+ focusStatus.value = PIPELINE_STATUSES[idx - 1]!
+ }
+}
+
+function goToNextStage() {
+ const idx = PIPELINE_STATUSES.indexOf(focusStatus.value)
+ if (idx < PIPELINE_STATUSES.length - 1) {
+ focusStatus.value = PIPELINE_STATUSES[idx + 1]!
+ }
+}
+
function handleKeyNavigation(event: KeyboardEvent) {
if (event.key === 'Escape' && showDocPreview.value) {
closeDocPreview()
@@ -369,6 +845,28 @@ function handleKeyNavigation(event: KeyboardEvent) {
event.preventDefault()
goToNextCard()
}
+
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault()
+ goToPreviousStage()
+ }
+
+ if (event.key === 'ArrowRight') {
+ event.preventDefault()
+ goToNextStage()
+ }
+
+ // Number keys 1-9 trigger status transition buttons
+ const num = parseInt(event.key)
+ if (num >= 1 && num <= 9 && allowedTransitions.value.length >= num) {
+ event.preventDefault()
+ const targetStatus = allowedTransitions.value[num - 1]!
+ if (targetStatus === 'interview') {
+ openInterviewScheduler()
+ } else {
+ changeStatus(targetStatus)
+ }
+ }
}
onMounted(() => {
@@ -600,7 +1098,12 @@ function closeDocPreview() {
-
+
@@ -699,9 +1202,19 @@ function closeDocPreview() {
-
-
ββ
-
navigate
+
+
+ ββ
+ candidates
+
+
+ ββ
+ stages
+
+
+ 1-9
+ actions
+
@@ -738,6 +1251,16 @@ function closeDocPreview() {
{{ statusCounts[status] ?? 0 }}
+
+
+
+
+
+
@@ -748,8 +1271,9 @@ function closeDocPreview() {
-
-
+
+
+
+
+
+
+
+
+
+
+ {{ currentSortLabel }}
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+
+
+ {{ activeFilterCount }}
+
+
+
+
+
+
+
+
+
+
Score
+
+
+ {{ opt.label }}
+
+
+
+
+
+
+
Interview
+
+
+ {{ opt.label }}
+
+
+
+
+
+
+
+ Clear filters
+
+
+
{{ filteredApplications.length }} candidate{{ filteredApplications.length === 1 ? '' : 's' }}
- matching
+
+ {{ hasActiveFilters ? ' filtered' : ' matching' }}
+
+
+
+ of {{ focusedApplications.length }}
@@ -776,11 +1431,18 @@ function closeDocPreview() {
- {{ searchTerm.trim() ? 'No matching candidates' : `No candidates yet` }}
+ {{ (searchTerm.trim() || hasActiveFilters) ? 'No matching candidates' : `No candidates yet` }}
- {{ searchTerm.trim() ? 'Try a different search term.' : `No one in ${formatStatusLabel(focusStatus)} stage.` }}
+ {{ (searchTerm.trim() || hasActiveFilters) ? 'Try adjusting your search or filters.' : `No one in ${formatStatusLabel(focusStatus)} stage.` }}
+
+ Clear filters
+
{{ app.candidateFirstName }} {{ app.candidateLastName }}
-
- {{ app.candidateEmail }}
-
+ {{ app.candidateEmail }}
{{ timeAgo(app.createdAt) }}
+
+
+
@@ -849,14 +1512,15 @@ function closeDocPreview() {
{{ transitionLabels[nextStatus] ?? nextStatus }}
+ {{ idx + 1 }}
@@ -892,15 +1556,39 @@ function closeDocPreview() {
-
+
{{ currentSummary.candidateEmail }}
-
+
{{ resolvedCurrentApplication.candidate.phone }}
+
+
+ {{ currentSummary.score }} pts
+
+
+
+ Applied {{ new Date(currentSummary.createdAt).toLocaleDateString() }}
+
+
+ Β· Updated {{ new Date(currentSummary.updatedAt).toLocaleDateString() }}
+
+
@@ -947,6 +1635,21 @@ function closeDocPreview() {
>
Profile
+
+ Interviews
+
+ ({{ currentApplicationInterviews.length }})
+
+
-
-
-
-
-
-
Name
-
- {{ currentSummary.candidateFirstName }} {{ currentSummary.candidateLastName }}
-
-
-
-
Email
-
- {{ currentSummary.candidateEmail }}
-
-
-
-
Phone
-
- {{ resolvedCurrentApplication.candidate.phone }}
-
-
-
-
Score
-
-
- {{ currentSummary.score }} pts
-
- β
-
-
-
-
-
-
-
-
-
-
-
Applied
-
- {{ new Date(currentSummary.createdAt).toLocaleDateString() }}
-
-
-
-
Updated
-
- {{ new Date(currentSummary.updatedAt).toLocaleDateString() }}
-
-
-
-
-
@@ -1082,6 +1714,364 @@ function closeDocPreview() {
+
+
+