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
44definePageMeta ({
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-
194169function 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