|
2 | 2 | import { |
3 | 3 | X, User, Calendar, Clock, Hash, MessageSquare, FileText, |
4 | 4 | ExternalLink, Mail, Phone, Upload, Download, Eye, Trash2, |
5 | | - ArrowLeft, AlertTriangle, Brain, |
| 5 | + ArrowLeft, AlertTriangle, Brain, History, |
6 | 6 | } from 'lucide-vue-next' |
7 | 7 | import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly' |
8 | 8 |
|
@@ -34,7 +34,7 @@ const hasSubNav = computed(() => { |
34 | 34 | // Tabs |
35 | 35 | // ───────────────────────────────────────────── |
36 | 36 |
|
37 | | -const activeTab = ref<'overview' | 'documents' | 'responses' | 'ai_analysis'>('overview') |
| 37 | +const activeTab = ref<'overview' | 'documents' | 'responses' | 'ai_analysis' | 'timeline'>('overview') |
38 | 38 |
|
39 | 39 | // ───────────────────────────────────────────── |
40 | 40 | // Fetch application detail |
@@ -288,12 +288,109 @@ function onKeydown(e: KeyboardEvent) { |
288 | 288 | onMounted(() => window.addEventListener('keydown', onKeydown)) |
289 | 289 | onUnmounted(() => window.removeEventListener('keydown', onKeydown)) |
290 | 290 |
|
| 291 | +// ───────────────────────────────────────────── |
| 292 | +// Timeline data for the candidate |
| 293 | +// ───────────────────────────────────────────── |
| 294 | +
|
| 295 | +interface TimelineEntry { |
| 296 | + id: string |
| 297 | + action: string |
| 298 | + resourceType: string |
| 299 | + resourceId: string |
| 300 | + metadata: Record<string, unknown> | null |
| 301 | + createdAt: string |
| 302 | + actorName: string | null |
| 303 | + actorEmail: string | null |
| 304 | + resourceName: string | null |
| 305 | + jobTitle: string | null |
| 306 | + candidateName: string | null |
| 307 | +} |
| 308 | +
|
| 309 | +const timelineItems = ref<TimelineEntry[]>([]) |
| 310 | +const timelineLoading = ref(false) |
| 311 | +const timelineError = ref<string | null>(null) |
| 312 | +const timelineLoaded = ref(false) |
| 313 | +
|
| 314 | +const timelineActionLabels: Record<string, string> = { |
| 315 | + created: 'Created', |
| 316 | + updated: 'Updated', |
| 317 | + deleted: 'Deleted', |
| 318 | + status_changed: 'Status changed', |
| 319 | + comment_added: 'Comment added', |
| 320 | + scored: 'Scored', |
| 321 | + scheduled: 'Scheduled', |
| 322 | +} |
| 323 | +
|
| 324 | +function formatTimelineDate(dateStr: string) { |
| 325 | + const d = new Date(dateStr) |
| 326 | + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) |
| 327 | +} |
| 328 | +
|
| 329 | +function getTimelineActionColor(action: string): string { |
| 330 | + switch (action) { |
| 331 | + case 'created': return 'bg-green-500' |
| 332 | + case 'status_changed': return 'bg-blue-500' |
| 333 | + case 'updated': return 'bg-amber-500' |
| 334 | + case 'deleted': return 'bg-danger-500' |
| 335 | + case 'comment_added': return 'bg-violet-500' |
| 336 | + case 'scored': return 'bg-teal-500' |
| 337 | + case 'scheduled': return 'bg-brand-500' |
| 338 | + default: return 'bg-surface-400' |
| 339 | + } |
| 340 | +} |
| 341 | +
|
| 342 | +function describeTimelineItem(item: TimelineEntry): string { |
| 343 | + const actor = item.actorName ?? item.actorEmail ?? 'System' |
| 344 | + const action = timelineActionLabels[item.action] ?? item.action |
| 345 | + const resource = item.resourceType |
| 346 | +
|
| 347 | + if (item.action === 'status_changed' && item.metadata) { |
| 348 | + const from = item.metadata.from_status ?? item.metadata.fromStatus |
| 349 | + const to = item.metadata.to_status ?? item.metadata.toStatus |
| 350 | + if (from && to) return `${actor} changed ${resource} status from ${from} to ${to}` |
| 351 | + } |
| 352 | +
|
| 353 | + if (item.action === 'scored' && item.metadata) { |
| 354 | + const score = item.metadata.score |
| 355 | + if (score != null) return `${actor} scored ${resource} — ${score} pts` |
| 356 | + } |
| 357 | +
|
| 358 | + return `${actor} ${action.toLowerCase()} ${resource}` |
| 359 | +} |
| 360 | +
|
| 361 | +async function loadTimeline() { |
| 362 | + if (!candidateId.value) return |
| 363 | + timelineLoading.value = true |
| 364 | + timelineError.value = null |
| 365 | + try { |
| 366 | + const result = await $fetch<{ items: TimelineEntry[] }>('/api/activity-log/candidate-timeline', { |
| 367 | + query: { candidateId: candidateId.value }, |
| 368 | + }) |
| 369 | + timelineItems.value = result.items |
| 370 | + timelineLoaded.value = true |
| 371 | + } catch (err: any) { |
| 372 | + timelineError.value = err?.data?.statusMessage ?? 'Failed to load timeline' |
| 373 | + } finally { |
| 374 | + timelineLoading.value = false |
| 375 | + } |
| 376 | +} |
| 377 | +
|
| 378 | +// Load timeline data lazily when tab is selected |
| 379 | +watch(activeTab, (tab) => { |
| 380 | + if (tab === 'timeline' && !timelineLoaded.value && candidateId.value) { |
| 381 | + loadTimeline() |
| 382 | + } |
| 383 | +}) |
| 384 | +
|
291 | 385 | // Reset state when switching to a different application |
292 | 386 | watch(() => props.applicationId, () => { |
293 | 387 | isEditingNotes.value = false |
294 | 388 | activeTab.value = 'overview' |
295 | 389 | uploadError.value = null |
296 | 390 | showDocDeleteConfirm.value = null |
| 391 | + timelineItems.value = [] |
| 392 | + timelineLoaded.value = false |
| 393 | + timelineError.value = null |
297 | 394 | closePreview() |
298 | 395 | }) |
299 | 396 |
|
@@ -432,6 +529,16 @@ function formatInterviewDate(dateStr: string) { |
432 | 529 | <Brain class="size-3.5" /> |
433 | 530 | AI Analysis |
434 | 531 | </button> |
| 532 | + <button |
| 533 | + class="cursor-pointer px-3 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px inline-flex items-center gap-1.5" |
| 534 | + :class="activeTab === 'timeline' |
| 535 | + ? 'border-brand-600 text-brand-600' |
| 536 | + : 'border-transparent text-surface-500 hover:text-surface-700 hover:border-surface-300 dark:hover:text-surface-300'" |
| 537 | + @click="activeTab = 'timeline'" |
| 538 | + > |
| 539 | + <History class="size-3.5" /> |
| 540 | + Timeline |
| 541 | + </button> |
435 | 542 | </div> |
436 | 543 | </div> |
437 | 544 |
|
@@ -882,6 +989,82 @@ function formatInterviewDate(dateStr: string) { |
882 | 989 | <ScoreBreakdown :application-id="props.applicationId" @scored="refresh(); emit('updated')" /> |
883 | 990 | </div> |
884 | 991 |
|
| 992 | + <!-- ═══════════════════════════════════════ --> |
| 993 | + <!-- TIMELINE TAB --> |
| 994 | + <!-- ═══════════════════════════════════════ --> |
| 995 | + <div v-if="activeTab === 'timeline'" class="space-y-1"> |
| 996 | + <!-- Loading --> |
| 997 | + <div v-if="timelineLoading" class="text-center py-12 text-surface-400"> |
| 998 | + <div class="size-6 rounded-full border-2 border-brand-200 border-t-brand-600 dark:border-brand-800 dark:border-t-brand-400 animate-spin mx-auto mb-3" /> |
| 999 | + Loading timeline… |
| 1000 | + </div> |
| 1001 | + |
| 1002 | + <!-- Error --> |
| 1003 | + <div |
| 1004 | + v-else-if="timelineError" |
| 1005 | + class="rounded-xl border border-danger-200/80 dark:border-danger-800/60 bg-danger-50 dark:bg-danger-950/40 p-5 text-center" |
| 1006 | + > |
| 1007 | + <AlertTriangle class="size-6 text-danger-400 mx-auto mb-2" /> |
| 1008 | + <p class="text-sm text-danger-700 dark:text-danger-400">{{ timelineError }}</p> |
| 1009 | + <button |
| 1010 | + class="mt-3 text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium" |
| 1011 | + @click="loadTimeline" |
| 1012 | + > |
| 1013 | + Retry |
| 1014 | + </button> |
| 1015 | + </div> |
| 1016 | + |
| 1017 | + <!-- Empty --> |
| 1018 | + <div |
| 1019 | + v-else-if="timelineItems.length === 0" |
| 1020 | + class="rounded-xl border border-surface-200/80 dark:border-surface-800/60 bg-white dark:bg-surface-950 p-8 text-center shadow-sm shadow-surface-900/[0.03] dark:shadow-none" |
| 1021 | + > |
| 1022 | + <div class="flex size-14 items-center justify-center rounded-2xl bg-surface-100 dark:bg-surface-800/60 mx-auto mb-3"> |
| 1023 | + <History class="size-6 text-surface-400 dark:text-surface-500" /> |
| 1024 | + </div> |
| 1025 | + <p class="text-sm font-medium text-surface-600 dark:text-surface-300">No activity recorded yet.</p> |
| 1026 | + <p class="text-xs text-surface-400 dark:text-surface-500 mt-1">Activity for this candidate will appear here.</p> |
| 1027 | + </div> |
| 1028 | + |
| 1029 | + <!-- Timeline list --> |
| 1030 | + <div v-else class="relative"> |
| 1031 | + <!-- Vertical line --> |
| 1032 | + <div class="absolute left-[11px] top-2 bottom-2 w-px bg-surface-200 dark:bg-surface-700" /> |
| 1033 | + |
| 1034 | + <div |
| 1035 | + v-for="item in timelineItems" |
| 1036 | + :key="item.id" |
| 1037 | + class="relative flex gap-3 py-2.5 group" |
| 1038 | + > |
| 1039 | + <!-- Dot --> |
| 1040 | + <div class="relative z-10 mt-1 shrink-0"> |
| 1041 | + <div |
| 1042 | + class="size-[9px] rounded-full ring-2 ring-white dark:ring-surface-900" |
| 1043 | + :class="getTimelineActionColor(item.action)" |
| 1044 | + /> |
| 1045 | + </div> |
| 1046 | + |
| 1047 | + <!-- Content --> |
| 1048 | + <div class="min-w-0 flex-1"> |
| 1049 | + <p class="text-sm text-surface-700 dark:text-surface-200 leading-snug"> |
| 1050 | + {{ describeTimelineItem(item) }} |
| 1051 | + </p> |
| 1052 | + <div class="flex items-center gap-2 mt-0.5"> |
| 1053 | + <span class="text-[11px] text-surface-400 dark:text-surface-500 tabular-nums"> |
| 1054 | + {{ formatTimelineDate(item.createdAt) }} |
| 1055 | + </span> |
| 1056 | + <span |
| 1057 | + v-if="item.jobTitle" |
| 1058 | + class="text-[10px] text-surface-400 dark:text-surface-500 bg-surface-100 dark:bg-surface-800 rounded px-1.5 py-0.5 truncate max-w-[140px]" |
| 1059 | + > |
| 1060 | + {{ item.jobTitle }} |
| 1061 | + </span> |
| 1062 | + </div> |
| 1063 | + </div> |
| 1064 | + </div> |
| 1065 | + </div> |
| 1066 | + </div> |
| 1067 | + |
885 | 1068 | </template> |
886 | 1069 | </div> |
887 | 1070 | </aside> |
|
0 commit comments