Skip to content

Commit 9188d3b

Browse files
committed
feat: implement sortable candidate and application tables with improved UI
1 parent 2c01f77 commit 9188d3b

2 files changed

Lines changed: 279 additions & 184 deletions

File tree

app/pages/dashboard/applications/index.vue

Lines changed: 140 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { FileText, Search, X, ChevronDown, Briefcase, Mail, Clock } from 'lucide-vue-next'
2+
import { FileText, Search, X, ChevronDown, Briefcase, Mail, Clock, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-vue-next'
33
44
definePageMeta({
55
layout: 'dashboard',
@@ -83,29 +83,20 @@ onUnmounted(() => document.removeEventListener('mousedown', handleJobDropdownOut
8383
8484
// ── Sorting ───────────────────────────────────────────────────────────────────
8585
86-
type SortOption = 'newest' | 'oldest' | 'score-high' | 'score-low' | 'name-az' | 'name-za'
87-
const activeSort = useState<SortOption>('app-sort', () => 'newest')
88-
const sortDropdownOpen = ref(false)
89-
const sortDropdownRef = ref<HTMLElement | null>(null)
90-
91-
const sortOptions: { value: SortOption, label: string }[] = [
92-
{ value: 'newest', label: 'Newest first' },
93-
{ value: 'oldest', label: 'Oldest first' },
94-
{ value: 'score-high', label: 'Score: high to low' },
95-
{ value: 'score-low', label: 'Score: low to high' },
96-
{ value: 'name-az', label: 'Name: A → Z' },
97-
{ value: 'name-za', label: 'Name: Z → A' },
98-
]
99-
100-
const activeSortLabel = computed(() => sortOptions.find(o => o.value === activeSort.value)?.label ?? 'Newest first')
101-
102-
function handleSortDropdownOutside(e: MouseEvent) {
103-
if (sortDropdownRef.value && !sortDropdownRef.value.contains(e.target as Node)) {
104-
sortDropdownOpen.value = false
86+
type SortKey = 'name' | 'email' | 'job' | 'status' | 'score' | 'created'
87+
type SortDir = 'asc' | 'desc'
88+
89+
const sortKey = ref<SortKey>('created')
90+
const sortDir = ref<SortDir>('desc')
91+
92+
function toggleSort(key: SortKey) {
93+
if (sortKey.value === key) {
94+
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
95+
} else {
96+
sortKey.value = key
97+
sortDir.value = key === 'created' || key === 'score' ? 'desc' : 'asc'
10598
}
10699
}
107-
onMounted(() => document.addEventListener('mousedown', handleSortDropdownOutside))
108-
onUnmounted(() => document.removeEventListener('mousedown', handleSortDropdownOutside))
109100
110101
// ── Filtered + sorted list ────────────────────────────────────────────────────
111102
@@ -128,20 +119,21 @@ const filteredApplications = computed(() => {
128119
}
129120
130121
// Sort
122+
const dir = sortDir.value === 'asc' ? 1 : -1
131123
list.sort((a, b) => {
132-
switch (activeSort.value) {
133-
case 'newest':
134-
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
135-
case 'oldest':
136-
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
137-
case 'score-high':
138-
return (b.score ?? -1) - (a.score ?? -1)
139-
case 'score-low':
140-
return (a.score ?? -1) - (b.score ?? -1)
141-
case 'name-az':
142-
return `${a.candidateFirstName} ${a.candidateLastName}`.localeCompare(`${b.candidateFirstName} ${b.candidateLastName}`)
143-
case 'name-za':
144-
return `${b.candidateFirstName} ${b.candidateLastName}`.localeCompare(`${a.candidateFirstName} ${a.candidateLastName}`)
124+
switch (sortKey.value) {
125+
case 'name':
126+
return dir * `${a.candidateFirstName} ${a.candidateLastName}`.localeCompare(`${b.candidateFirstName} ${b.candidateLastName}`)
127+
case 'email':
128+
return dir * a.candidateEmail.localeCompare(b.candidateEmail)
129+
case 'job':
130+
return dir * a.jobTitle.localeCompare(b.jobTitle)
131+
case 'status':
132+
return dir * a.status.localeCompare(b.status)
133+
case 'score':
134+
return dir * ((a.score ?? -1) - (b.score ?? -1))
135+
case 'created':
136+
return dir * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
145137
default:
146138
return 0
147139
}
@@ -174,23 +166,6 @@ function timeAgo(date: string | Date) {
174166
return new Date(date).toLocaleDateString()
175167
}
176168
177-
function candidateInitials(first: string, last: string) {
178-
return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
179-
}
180-
181-
function initialsColor(name: string) {
182-
const colors = [
183-
'bg-brand-100 text-brand-700 dark:bg-brand-900 dark:text-brand-300',
184-
'bg-info-100 text-info-700 dark:bg-info-900 dark:text-info-300',
185-
'bg-success-100 text-success-700 dark:bg-success-900 dark:text-success-300',
186-
'bg-warning-100 text-warning-700 dark:bg-warning-900 dark:text-warning-300',
187-
'bg-danger-100 text-danger-700 dark:bg-danger-900 dark:text-danger-300',
188-
]
189-
let hash = 0
190-
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash)
191-
return colors[Math.abs(hash) % colors.length]
192-
}
193-
194169
function scoreClass(score: number) {
195170
if (score >= 75) return 'bg-success-50 text-success-700 ring-success-200 dark:bg-success-950 dark:text-success-400 dark:ring-success-800'
196171
if (score >= 40) return 'bg-warning-50 text-warning-700 ring-warning-200 dark:bg-warning-950 dark:text-warning-400 dark:ring-warning-800'
@@ -226,7 +201,7 @@ const statusLabels: Record<Status, string> = {
226201
</script>
227202

228203
<template>
229-
<div class="mx-auto max-w-5xl">
204+
<div class="mx-auto max-w-6xl">
230205
<!-- Header -->
231206
<div class="flex items-center justify-between mb-6">
232207
<div>
@@ -321,38 +296,6 @@ const statusLabels: Record<Status, string> = {
321296
</button>
322297
</div>
323298

324-
<!-- Results bar -->
325-
<div class="flex items-center justify-between mb-3">
326-
<p class="text-xs font-medium text-surface-500 dark:text-surface-400 uppercase tracking-wide">
327-
{{ filteredApplications.length }} application{{ filteredApplications.length === 1 ? '' : 's' }}
328-
</p>
329-
330-
<!-- Sort dropdown -->
331-
<div ref="sortDropdownRef" class="relative">
332-
<button
333-
class="inline-flex items-center gap-1.5 text-xs text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 transition-colors"
334-
@click="sortDropdownOpen = !sortDropdownOpen"
335-
>
336-
{{ activeSortLabel }}
337-
<ChevronDown class="size-3" />
338-
</button>
339-
<div
340-
v-if="sortDropdownOpen"
341-
class="absolute right-0 top-full mt-1 z-20 w-48 rounded-lg border border-surface-200 dark:border-surface-800 bg-white dark:bg-surface-900 shadow-lg py-1"
342-
>
343-
<button
344-
v-for="opt in sortOptions"
345-
:key="opt.value"
346-
class="w-full text-left px-3 py-2 text-sm hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors"
347-
:class="activeSort === opt.value ? 'text-brand-600 font-medium' : 'text-surface-700 dark:text-surface-300'"
348-
@click="activeSort = opt.value; sortDropdownOpen = false"
349-
>
350-
{{ opt.label }}
351-
</button>
352-
</div>
353-
</div>
354-
</div>
355-
356299
<!-- Loading -->
357300
<div v-if="fetchStatus === 'pending'" class="text-center py-16 text-surface-400">
358301
Loading applications…
@@ -397,69 +340,121 @@ const statusLabels: Record<Status, string> = {
397340
</button>
398341
</div>
399342

400-
<!-- Application cards -->
401-
<div v-else class="space-y-2">
402-
<NuxtLink
403-
v-for="app in filteredApplications"
404-
:key="app.id"
405-
:to="$localePath(`/dashboard/applications/${app.id}`)"
406-
class="flex items-start gap-4 rounded-lg border border-surface-200 dark:border-surface-800 bg-white dark:bg-surface-900 px-4 py-4 hover:border-surface-300 dark:hover:border-surface-700 hover:shadow-sm transition-all group"
407-
>
408-
<!-- Avatar -->
409-
<div
410-
class="size-10 shrink-0 rounded-full flex items-center justify-center text-sm font-semibold select-none"
411-
:class="initialsColor(`${app.candidateFirstName} ${app.candidateLastName}`)"
412-
>
413-
{{ candidateInitials(app.candidateFirstName, app.candidateLastName) }}
414-
</div>
415-
416-
<!-- Info -->
417-
<div class="min-w-0 flex-1">
418-
<!-- Row 1: name + badges -->
419-
<div class="flex items-center gap-2 flex-wrap">
420-
<h3 class="text-sm font-semibold text-surface-900 dark:text-surface-100 group-hover:text-brand-600 transition-colors truncate">
421-
{{ app.candidateFirstName }} {{ app.candidateLastName }}
422-
</h3>
423-
<!-- Status badge -->
424-
<span
425-
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium capitalize"
426-
:class="statusBadgeClasses[app.status] ?? 'bg-surface-100 text-surface-600'"
343+
<!-- Application table -->
344+
<div v-else>
345+
<div class="overflow-x-auto rounded-lg border border-surface-200 dark:border-surface-800">
346+
<table class="w-full text-sm">
347+
<thead>
348+
<tr class="bg-surface-50 dark:bg-surface-800/50 border-b border-surface-200 dark:border-surface-800">
349+
<th class="text-left px-4 py-3 font-medium text-surface-500 dark:text-surface-400">
350+
<button class="inline-flex items-center gap-1 hover:text-surface-900 dark:hover:text-surface-100 transition-colors" @click="toggleSort('name')">
351+
Candidate
352+
<ArrowUp v-if="sortKey === 'name' && sortDir === 'asc'" class="size-3.5" />
353+
<ArrowDown v-else-if="sortKey === 'name' && sortDir === 'desc'" class="size-3.5" />
354+
<ArrowUpDown v-else class="size-3.5 opacity-40" />
355+
</button>
356+
</th>
357+
<th class="text-left px-4 py-3 font-medium text-surface-500 dark:text-surface-400 hidden lg:table-cell">
358+
<button class="inline-flex items-center gap-1 hover:text-surface-900 dark:hover:text-surface-100 transition-colors" @click="toggleSort('email')">
359+
Email
360+
<ArrowUp v-if="sortKey === 'email' && sortDir === 'asc'" class="size-3.5" />
361+
<ArrowDown v-else-if="sortKey === 'email' && sortDir === 'desc'" class="size-3.5" />
362+
<ArrowUpDown v-else class="size-3.5 opacity-40" />
363+
</button>
364+
</th>
365+
<th class="text-left px-4 py-3 font-medium text-surface-500 dark:text-surface-400 hidden md:table-cell">
366+
<button class="inline-flex items-center gap-1 hover:text-surface-900 dark:hover:text-surface-100 transition-colors" @click="toggleSort('job')">
367+
Job
368+
<ArrowUp v-if="sortKey === 'job' && sortDir === 'asc'" class="size-3.5" />
369+
<ArrowDown v-else-if="sortKey === 'job' && sortDir === 'desc'" class="size-3.5" />
370+
<ArrowUpDown v-else class="size-3.5 opacity-40" />
371+
</button>
372+
</th>
373+
<th class="text-left px-4 py-3 font-medium text-surface-500 dark:text-surface-400">
374+
<button class="inline-flex items-center gap-1 hover:text-surface-900 dark:hover:text-surface-100 transition-colors" @click="toggleSort('status')">
375+
Status
376+
<ArrowUp v-if="sortKey === 'status' && sortDir === 'asc'" class="size-3.5" />
377+
<ArrowDown v-else-if="sortKey === 'status' && sortDir === 'desc'" class="size-3.5" />
378+
<ArrowUpDown v-else class="size-3.5 opacity-40" />
379+
</button>
380+
</th>
381+
<th class="text-center px-4 py-3 font-medium text-surface-500 dark:text-surface-400 hidden sm:table-cell">
382+
<button class="inline-flex items-center gap-1 hover:text-surface-900 dark:hover:text-surface-100 transition-colors" @click="toggleSort('score')">
383+
Score
384+
<ArrowUp v-if="sortKey === 'score' && sortDir === 'asc'" class="size-3.5" />
385+
<ArrowDown v-else-if="sortKey === 'score' && sortDir === 'desc'" class="size-3.5" />
386+
<ArrowUpDown v-else class="size-3.5 opacity-40" />
387+
</button>
388+
</th>
389+
<th class="text-left px-4 py-3 font-medium text-surface-500 dark:text-surface-400">
390+
<button class="inline-flex items-center gap-1 hover:text-surface-900 dark:hover:text-surface-100 transition-colors" @click="toggleSort('created')">
391+
Applied
392+
<ArrowUp v-if="sortKey === 'created' && sortDir === 'asc'" class="size-3.5" />
393+
<ArrowDown v-else-if="sortKey === 'created' && sortDir === 'desc'" class="size-3.5" />
394+
<ArrowUpDown v-else class="size-3.5 opacity-40" />
395+
</button>
396+
</th>
397+
</tr>
398+
</thead>
399+
<tbody class="divide-y divide-surface-100 dark:divide-surface-800">
400+
<tr
401+
v-for="app in filteredApplications"
402+
:key="app.id"
403+
class="group bg-white dark:bg-surface-900 hover:bg-surface-50 dark:hover:bg-surface-800/60 transition-colors cursor-pointer"
404+
@click="$router.push($localePath(`/dashboard/applications/${app.id}`))"
427405
>
428-
<span class="size-1.5 rounded-full" :class="statusDotClasses[app.status] ?? 'bg-surface-400'" />
429-
{{ statusLabels[app.status as Status] ?? app.status }}
430-
</span>
431-
<!-- Score badge -->
432-
<span
433-
v-if="app.score != null"
434-
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ring-1 ring-inset"
435-
:class="scoreClass(app.score)"
436-
>
437-
{{ app.score }}%
438-
</span>
439-
</div>
440-
441-
<!-- Row 2: job title -->
442-
<div class="flex items-center gap-1.5 mt-1 text-sm text-surface-600 dark:text-surface-300">
443-
<Briefcase class="size-3.5 shrink-0 text-surface-400" />
444-
<span class="truncate">{{ app.jobTitle }}</span>
445-
</div>
446-
447-
<!-- Row 3: email + time -->
448-
<div class="flex items-center gap-3 mt-1.5 text-xs text-surface-400">
449-
<span class="inline-flex items-center gap-1">
450-
<Mail class="size-3" />
451-
{{ app.candidateEmail }}
452-
</span>
453-
<span class="inline-flex items-center gap-1">
454-
<Clock class="size-3" />
455-
{{ timeAgo(app.createdAt) }}
456-
</span>
457-
</div>
458-
</div>
459-
</NuxtLink>
406+
<td class="px-4 py-3">
407+
<NuxtLink
408+
:to="$localePath(`/dashboard/applications/${app.id}`)"
409+
class="font-semibold text-surface-900 dark:text-surface-100 group-hover:text-brand-600 transition-colors whitespace-nowrap"
410+
>
411+
{{ app.candidateFirstName }} {{ app.candidateLastName }}
412+
</NuxtLink>
413+
</td>
414+
<td class="px-4 py-3 text-surface-500 dark:text-surface-400 hidden lg:table-cell">
415+
<span class="inline-flex items-center gap-1.5">
416+
<Mail class="size-3.5 shrink-0" />
417+
<span class="truncate max-w-[200px]">{{ app.candidateEmail }}</span>
418+
</span>
419+
</td>
420+
<td class="px-4 py-3 text-surface-600 dark:text-surface-300 hidden md:table-cell">
421+
<span class="inline-flex items-center gap-1.5 truncate max-w-[200px]">
422+
<Briefcase class="size-3.5 shrink-0 text-surface-400" />
423+
{{ app.jobTitle }}
424+
</span>
425+
</td>
426+
<td class="px-4 py-3">
427+
<span
428+
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium capitalize whitespace-nowrap"
429+
:class="statusBadgeClasses[app.status] ?? 'bg-surface-100 text-surface-600'"
430+
>
431+
<span class="size-1.5 rounded-full" :class="statusDotClasses[app.status] ?? 'bg-surface-400'" />
432+
{{ statusLabels[app.status as Status] ?? app.status }}
433+
</span>
434+
</td>
435+
<td class="px-4 py-3 text-center hidden sm:table-cell">
436+
<span
437+
v-if="app.score != null"
438+
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ring-1 ring-inset"
439+
:class="scoreClass(app.score)"
440+
>
441+
{{ app.score }}%
442+
</span>
443+
<span v-else class="text-surface-300 dark:text-surface-600">—</span>
444+
</td>
445+
<td class="px-4 py-3 text-surface-400 whitespace-nowrap">
446+
<span class="inline-flex items-center gap-1.5">
447+
<Clock class="size-3.5 shrink-0" />
448+
{{ timeAgo(app.createdAt) }}
449+
</span>
450+
</td>
451+
</tr>
452+
</tbody>
453+
</table>
454+
</div>
460455

461456
<!-- Footer count -->
462-
<p class="text-xs text-surface-400 pt-2">
457+
<p class="text-xs text-surface-400 pt-3">
463458
Showing {{ filteredApplications.length }} of {{ total }} application{{ total === 1 ? '' : 's' }}
464459
</p>
465460
</div>

0 commit comments

Comments
 (0)