210 lines
6.8 KiB
Vue
210 lines
6.8 KiB
Vue
<template>
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold">Activity Timeline</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
@click="loadActivities"
|
|
:disabled="loading"
|
|
>
|
|
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div v-if="loading && activities.length === 0" class="text-center py-8 text-muted-foreground">
|
|
Loading timeline...
|
|
</div>
|
|
|
|
<div v-else-if="activities.length === 0" class="text-center py-8 text-muted-foreground">
|
|
<Clock class="h-12 w-12 mx-auto mb-2 opacity-50" />
|
|
<p>No activity recorded</p>
|
|
</div>
|
|
|
|
<div v-else class="relative">
|
|
<!-- Timeline line -->
|
|
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-border" />
|
|
|
|
<!-- Timeline items -->
|
|
<div class="space-y-6">
|
|
<div
|
|
v-for="activity in activities"
|
|
:key="activity.id"
|
|
class="relative pl-10"
|
|
>
|
|
<!-- Timeline dot -->
|
|
<div
|
|
class="absolute left-2.5 w-3 h-3 rounded-full border-2 border-background"
|
|
:class="getTimelineDotColor(activity.type)"
|
|
/>
|
|
|
|
<!-- Activity content -->
|
|
<div class="bg-card border rounded-lg p-4">
|
|
<div class="flex items-start gap-3">
|
|
<component
|
|
:is="getActivityIcon(activity.type)"
|
|
class="h-5 w-5 mt-0.5 flex-shrink-0"
|
|
:class="getActivityColor(activity.type)"
|
|
/>
|
|
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium">{{ getActivityTitle(activity.type) }}</p>
|
|
<p class="text-sm text-muted-foreground mt-1">{{ activity.description }}</p>
|
|
|
|
<!-- Metadata display -->
|
|
<div v-if="activity.activity_metadata" class="mt-2 text-xs text-muted-foreground">
|
|
<div v-if="activity.activity_metadata.old_status && activity.activity_metadata.new_status">
|
|
<Badge variant="outline" class="mr-2">{{ activity.activity_metadata.old_status }}</Badge>
|
|
→
|
|
<Badge variant="outline" class="ml-2">{{ activity.activity_metadata.new_status }}</Badge>
|
|
</div>
|
|
<div v-if="activity.activity_metadata.version">
|
|
Version {{ activity.activity_metadata.version }}
|
|
</div>
|
|
<div v-if="activity.activity_metadata.decision">
|
|
Decision: <Badge :variant="activity.activity_metadata.decision === 'approved' ? 'default' : 'destructive'">
|
|
{{ activity.activity_metadata.decision }}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 mt-2">
|
|
<Avatar class="h-5 w-5">
|
|
<AvatarFallback class="text-xs">
|
|
{{ getInitials(activity.user) }}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ activity.user.first_name }} {{ activity.user.last_name }}
|
|
</span>
|
|
<span class="text-xs text-muted-foreground">•</span>
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ formatTime(activity.created_at) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, watch } from 'vue'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import {
|
|
Clock,
|
|
FileText,
|
|
CheckCircle,
|
|
UserPlus,
|
|
MessageSquare,
|
|
RefreshCw
|
|
} from 'lucide-vue-next'
|
|
import type { Activity, ActivityType, UserInfo } from '@/types/activity'
|
|
import * as activityService from '@/services/activity'
|
|
|
|
interface Props {
|
|
taskId: number
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const activities = ref<Activity[]>([])
|
|
const loading = ref(false)
|
|
|
|
onMounted(() => {
|
|
loadActivities()
|
|
})
|
|
|
|
watch(() => props.taskId, () => {
|
|
loadActivities()
|
|
})
|
|
|
|
async function loadActivities() {
|
|
loading.value = true
|
|
try {
|
|
activities.value = await activityService.getTaskActivities(props.taskId)
|
|
} catch (error) {
|
|
console.error('Failed to load task activities:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function getInitials(user: UserInfo): string {
|
|
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase()
|
|
}
|
|
|
|
function getActivityIcon(type: ActivityType) {
|
|
const iconMap: Record<string, any> = {
|
|
task_created: FileText,
|
|
task_updated: FileText,
|
|
task_assigned: UserPlus,
|
|
task_status_changed: CheckCircle,
|
|
submission_created: FileText,
|
|
submission_reviewed: CheckCircle,
|
|
comment_added: MessageSquare
|
|
}
|
|
return iconMap[type] || FileText
|
|
}
|
|
|
|
function getActivityColor(type: ActivityType): string {
|
|
const colorMap: Record<string, string> = {
|
|
task_created: 'text-blue-500',
|
|
task_updated: 'text-blue-500',
|
|
task_assigned: 'text-purple-500',
|
|
task_status_changed: 'text-green-500',
|
|
submission_created: 'text-orange-500',
|
|
submission_reviewed: 'text-green-500',
|
|
comment_added: 'text-gray-500'
|
|
}
|
|
return colorMap[type] || 'text-gray-500'
|
|
}
|
|
|
|
function getTimelineDotColor(type: ActivityType): string {
|
|
const colorMap: Record<string, string> = {
|
|
task_created: 'bg-blue-500',
|
|
task_updated: 'bg-blue-500',
|
|
task_assigned: 'bg-purple-500',
|
|
task_status_changed: 'bg-green-500',
|
|
submission_created: 'bg-orange-500',
|
|
submission_reviewed: 'bg-green-500',
|
|
comment_added: 'bg-gray-500'
|
|
}
|
|
return colorMap[type] || 'bg-gray-500'
|
|
}
|
|
|
|
function getActivityTitle(type: ActivityType): string {
|
|
const titleMap: Record<string, string> = {
|
|
task_created: 'Task Created',
|
|
task_updated: 'Task Updated',
|
|
task_assigned: 'Task Assigned',
|
|
task_status_changed: 'Status Changed',
|
|
submission_created: 'Work Submitted',
|
|
submission_reviewed: 'Submission Reviewed',
|
|
comment_added: 'Comment Added'
|
|
}
|
|
return titleMap[type] || 'Activity'
|
|
}
|
|
|
|
function formatTime(timestamp: string): string {
|
|
const date = new Date(timestamp)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffMins = Math.floor(diffMs / 60000)
|
|
const diffHours = Math.floor(diffMs / 3600000)
|
|
const diffDays = Math.floor(diffMs / 86400000)
|
|
|
|
if (diffMins < 1) return 'Just now'
|
|
if (diffMins < 60) return `${diffMins}m ago`
|
|
if (diffHours < 24) return `${diffHours}h ago`
|
|
if (diffDays < 7) return `${diffDays}d ago`
|
|
|
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
|
|
}
|
|
</script>
|