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
22 changes: 21 additions & 1 deletion app/pages/dashboard/applications/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ useSeoMeta({
description: 'Manage applications across all jobs',
})

const route = useRoute()
const router = useRouter()

// ── Search ────────────────────────────────────────────────────────────────────
Expand All @@ -31,7 +32,26 @@ watch(searchInput, (val) => {
const STATUS_OPTIONS = ['new', 'screening', 'interview', 'offer', 'hired', 'rejected'] as const
type Status = typeof STATUS_OPTIONS[number]

const activeStatus = useState<Status | undefined>('app-filter-status', () => undefined)
const initialAppStatus = STATUS_OPTIONS.includes(route.query.status as any)
? (route.query.status as Status)
: undefined
const activeStatus = useState<Status | undefined>('app-filter-status', () => initialAppStatus)
// Ensure the state matches the URL on navigation (useState caches across client-side navigations)
if (initialAppStatus !== undefined) {
activeStatus.value = initialAppStatus
}

// Sync the URL when the status filter changes
watch(activeStatus, (newStatus) => {
const query = { ...route.query }
if (newStatus) {
query.status = newStatus
}
else {
delete query.status
}
router.replace({ query })
})
Comment on lines +35 to +54

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

Potential stale state issue when navigating without query params.

The guard at lines 40-42 only updates activeStatus when initialAppStatus !== undefined. If a user navigates to /dashboard/applications (no ?status= param) after previously visiting /dashboard/applications?status=new, the useState cache retains the old value ('new'), but the URL shows no filter.

This creates a mismatch: the UI filters by 'new' while the URL suggests no filter is active.

Proposed fix
 const initialAppStatus = STATUS_OPTIONS.includes(route.query.status as any)
   ? (route.query.status as Status)
   : undefined
 const activeStatus = useState<Status | undefined>('app-filter-status', () => initialAppStatus)
 // Ensure the state matches the URL on navigation (useState caches across client-side navigations)
-if (initialAppStatus !== undefined) {
-  activeStatus.value = initialAppStatus
-}
+activeStatus.value = initialAppStatus
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/dashboard/applications/index.vue` around lines 35 - 54, The cached
activeStatus can remain stale when route.query.status is absent; update the
initialization logic so activeStatus.value is always set to reflect
initialAppStatus (including when initialAppStatus is undefined) instead of only
when !== undefined. In practice, replace the conditional guard around
activeStatus.value assignment for the useState('app-filter-status') so that
activeStatus.value = initialAppStatus runs unconditionally (or explicitly set
activeStatus.value = undefined when route.query.status is missing), referencing
initialAppStatus, activeStatus, useState, route.query, watch and router.replace
to keep the UI state and URL in sync.


const statusFilter = computed(() => activeStatus.value)

Expand Down
66 changes: 47 additions & 19 deletions app/pages/dashboard/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,15 @@ const isEmpty = computed(() =>
<!-- Pipeline breakdown -->
<div class="lg:col-span-2 rounded-xl border border-surface-200 dark:border-surface-800 bg-white dark:bg-surface-900 p-6">
<div class="flex items-center justify-between mb-5">
<h2 class="text-base font-semibold text-surface-900 dark:text-surface-100">Pipeline Overview</h2>
<div class="flex items-center gap-3">
<h2 class="text-base font-semibold text-surface-900 dark:text-surface-100">Pipeline Overview</h2>
<span
v-if="pipelineTotal > 0"
class="inline-flex items-center rounded-full bg-surface-100 dark:bg-surface-800 px-2.5 py-0.5 text-xs font-semibold text-surface-600 dark:text-surface-300"
>
{{ pipelineTotal }} total
</span>
</div>
<NuxtLink
to="/dashboard/applications"
class="text-xs font-medium text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 transition-colors no-underline"
Expand All @@ -273,42 +281,62 @@ const isEmpty = computed(() =>
</NuxtLink>
</div>

<!-- Pipeline bar -->
<!-- Pipeline content -->
<div v-if="pipelineTotal > 0">
<div class="flex h-3 rounded-full overflow-hidden bg-surface-100 dark:bg-surface-800 mb-4">
<!-- Segmented progress bar -->
<div class="flex h-4 rounded-lg overflow-hidden bg-surface-100 dark:bg-surface-800 mb-6 gap-0.5">
<NuxtLink
v-for="segment in pipelineSegments"
:key="segment.status"
:to="`/dashboard/applications?status=${segment.status}`"
:title="`${segment.label}: ${segment.count}`"
class="transition-all hover:opacity-80 no-underline"
:title="`${segment.label}: ${segment.count} (${segment.pct}%)`"
class="transition-all duration-200 hover:opacity-80 hover:scale-y-110 origin-center no-underline first:rounded-l-lg last:rounded-r-lg"
:class="segment.bg"
:style="{ width: `${Math.max(segment.pct, 2)}%` }"
:style="{ width: `${Math.max(segment.pct, 3)}%` }"
/>
</div>

<!-- Legend -->
<div class="flex flex-wrap gap-x-5 gap-y-2">
<!-- Stage cards grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<NuxtLink
v-for="(config, status) in applicationStatusConfig"
:key="status"
:to="`/dashboard/applications?status=${status}`"
class="inline-flex items-center gap-2 text-xs no-underline group/legend"
class="group/stage relative rounded-lg border border-surface-100 dark:border-surface-800 p-3 hover:border-surface-300 dark:hover:border-surface-600 hover:shadow-sm transition-all no-underline"
>
<span class="size-2.5 rounded-full shrink-0" :class="config.bg" />
<span class="text-surface-500 dark:text-surface-400 group-hover/legend:text-surface-700 dark:group-hover/legend:text-surface-200 transition-colors">
{{ config.label }}
</span>
<span class="font-semibold text-surface-700 dark:text-surface-200">
{{ (pipeline as Record<string, number>)[status] ?? 0 }}
</span>
<!-- Subtle top accent line -->
<div class="absolute inset-x-0 top-0 h-0.5 rounded-t-lg opacity-60 group-hover/stage:opacity-100 transition-opacity" :class="config.bg" />

<div class="flex items-center gap-2 mb-2">
<span class="rounded-md p-1.5 bg-surface-50 dark:bg-surface-800 group-hover/stage:bg-surface-100 dark:group-hover/stage:bg-surface-700 transition-colors">
<component :is="config.icon" class="size-3.5" :class="config.color" />
</span>
<span class="text-xs font-medium text-surface-500 dark:text-surface-400 group-hover/stage:text-surface-700 dark:group-hover/stage:text-surface-200 transition-colors">
{{ config.label }}
</span>
</div>

<div class="flex items-baseline gap-1.5">
<span class="text-lg font-bold text-surface-900 dark:text-surface-100">
{{ (pipeline as Record<string, number>)[status] ?? 0 }}
</span>
<span
v-if="pipelineTotal > 0"
class="text-xs text-surface-400 dark:text-surface-500"
>
{{ Math.round(((pipeline as Record<string, number>)[status] ?? 0) / pipelineTotal * 100) }}%
</span>
</div>
</NuxtLink>
</div>
</div>

<div v-else class="text-center py-6">
<Inbox class="size-8 text-surface-300 dark:text-surface-600 mx-auto mb-2" />
<p class="text-sm text-surface-400 dark:text-surface-500">No applications in the pipeline yet.</p>
<div v-else class="text-center py-8">
<div class="rounded-full bg-surface-50 dark:bg-surface-800 p-3 w-fit mx-auto mb-3">
<Inbox class="size-6 text-surface-300 dark:text-surface-600" />
</div>
<p class="text-sm font-medium text-surface-500 dark:text-surface-400 mb-1">No applications yet</p>
<p class="text-xs text-surface-400 dark:text-surface-500">Applications will appear here as candidates apply.</p>
</div>
</div>

Expand Down
24 changes: 22 additions & 2 deletions app/pages/dashboard/jobs/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,28 @@ useSeoMeta({
description: 'Manage your job postings',
})

const statusFilter = ref<string | undefined>(undefined)
const viewMode = ref<'list' | 'gallery'>('list')
const route = useRoute()
const router = useRouter()

// Sync statusFilter with ?status= query param
const validStatuses = ['draft', 'open', 'closed', 'archived'] as const
const initialStatus = validStatuses.includes(route.query.status as any)
? (route.query.status as string)
: undefined
const statusFilter = ref<string | undefined>(initialStatus)

// Sync viewMode with ?view= query param
const initialView = route.query.view === 'gallery' ? 'gallery' : 'list'
const viewMode = ref<'list' | 'gallery'>(initialView)

// Keep URL in sync when statusFilter or viewMode change
watch([statusFilter, viewMode], ([newStatus, newView]) => {
const query: Record<string, string> = {}
if (newStatus) query.status = newStatus
if (newView !== 'list') query.view = newView
router.replace({ query })
})

const { jobs, total, fetchStatus, error, refresh } = useJobs({ status: statusFilter })

const statusTabs = [
Expand Down