282 lines
8.4 KiB
Vue
282 lines
8.4 KiB
Vue
<template>
|
|
<Card>
|
|
<CardHeader>
|
|
<div class="flex items-center justify-between">
|
|
<CardTitle>{{ title }}</CardTitle>
|
|
<div class="flex items-center gap-2">
|
|
<Select v-if="showFilters" v-model="selectedDays">
|
|
<SelectTrigger class="w-32">
|
|
<SelectValue placeholder="All time" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All time</SelectItem>
|
|
<SelectItem value="1">Last 24h</SelectItem>
|
|
<SelectItem value="7">Last 7 days</SelectItem>
|
|
<SelectItem value="30">Last 30 days</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
@click="handleRefresh"
|
|
:disabled="loading"
|
|
>
|
|
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ScrollArea :class="scrollHeight">
|
|
<div v-if="loading && activities.length === 0" class="text-center py-8 text-muted-foreground">
|
|
Loading activities...
|
|
</div>
|
|
|
|
<div v-else-if="activities.length === 0" class="text-center py-8 text-muted-foreground">
|
|
<Activity class="h-12 w-12 mx-auto mb-2 opacity-50" />
|
|
<p>No activity yet</p>
|
|
</div>
|
|
|
|
<div v-else class="space-y-4">
|
|
<div
|
|
v-for="activity in activities"
|
|
:key="activity.id"
|
|
class="flex gap-3 pb-4 border-b last:border-0"
|
|
>
|
|
<div class="flex-shrink-0 mt-1">
|
|
<Avatar class="h-8 w-8">
|
|
<AvatarImage
|
|
v-if="activity.user.avatar_url"
|
|
:src="getAvatarUrl(activity.user.avatar_url, activity.user.first_name, activity.user.last_name)"
|
|
/>
|
|
<AvatarImage
|
|
v-else
|
|
:src="getInitialsAvatarUrl(activity.user.first_name, activity.user.last_name)"
|
|
/>
|
|
<AvatarFallback>
|
|
{{ getInitials(activity.user) }}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</div>
|
|
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-start gap-2">
|
|
<component
|
|
:is="getActivityIcon(activity.type)"
|
|
class="h-4 w-4 mt-0.5 flex-shrink-0"
|
|
:class="getActivityColor(activity.type)"
|
|
/>
|
|
<div class="flex-1">
|
|
<p class="text-sm">{{ activity.description }}</p>
|
|
<p class="text-xs text-muted-foreground mt-1">
|
|
{{ formatTime(activity.created_at) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action buttons for navigating to related items -->
|
|
<div v-if="activity.task_id || activity.project_id" class="mt-2 flex gap-2">
|
|
<Button
|
|
v-if="activity.task_id"
|
|
variant="outline"
|
|
size="sm"
|
|
@click="navigateToTask(activity.task_id)"
|
|
>
|
|
View Task
|
|
</Button>
|
|
<Button
|
|
v-if="activity.project_id && !activity.task_id"
|
|
variant="outline"
|
|
size="sm"
|
|
@click="navigateToProject(activity.project_id)"
|
|
>
|
|
View Project
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="hasMore" class="text-center pt-4">
|
|
<Button
|
|
variant="outline"
|
|
@click="loadMore"
|
|
:disabled="loading"
|
|
>
|
|
Load More
|
|
</Button>
|
|
</div>
|
|
</ScrollArea>
|
|
</CardContent>
|
|
</Card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import {
|
|
Activity as ActivityIcon,
|
|
FileText,
|
|
CheckCircle,
|
|
UserPlus,
|
|
MessageSquare,
|
|
Image,
|
|
Film,
|
|
FolderPlus,
|
|
RefreshCw
|
|
} from 'lucide-vue-next'
|
|
import type { Activity, ActivityType, UserInfo } from '@/types/activity'
|
|
import * as activityService from '@/services/activity'
|
|
|
|
interface Props {
|
|
title?: string
|
|
projectId?: number
|
|
taskId?: number
|
|
userId?: number
|
|
showFilters?: boolean
|
|
scrollHeight?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
title: 'Activity Feed',
|
|
showFilters: true,
|
|
scrollHeight: 'h-[500px]'
|
|
})
|
|
|
|
const router = useRouter()
|
|
|
|
const activities = ref<Activity[]>([])
|
|
const loading = ref(false)
|
|
const selectedDays = ref<string>('all')
|
|
const hasMore = ref(false)
|
|
const currentSkip = ref(0)
|
|
const limit = 20
|
|
|
|
onMounted(() => {
|
|
loadActivities()
|
|
})
|
|
|
|
watch(selectedDays, () => {
|
|
currentSkip.value = 0
|
|
loadActivities()
|
|
})
|
|
|
|
async function loadActivities() {
|
|
loading.value = true
|
|
try {
|
|
const days = selectedDays.value === 'all' ? undefined : parseInt(selectedDays.value)
|
|
let result: Activity[] = []
|
|
|
|
if (props.taskId) {
|
|
result = await activityService.getTaskActivities(props.taskId, currentSkip.value, limit)
|
|
} else if (props.projectId) {
|
|
result = await activityService.getProjectActivities(props.projectId, currentSkip.value, limit, undefined, days)
|
|
} else if (props.userId) {
|
|
result = await activityService.getUserActivities(props.userId, currentSkip.value, limit, days)
|
|
} else {
|
|
result = await activityService.getRecentActivities(currentSkip.value, limit)
|
|
}
|
|
|
|
if (currentSkip.value === 0) {
|
|
activities.value = result
|
|
} else {
|
|
activities.value.push(...result)
|
|
}
|
|
|
|
hasMore.value = result.length === limit
|
|
} catch (error) {
|
|
console.error('Failed to load activities:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadMore() {
|
|
currentSkip.value += limit
|
|
await loadActivities()
|
|
}
|
|
|
|
async function handleRefresh() {
|
|
currentSkip.value = 0
|
|
await loadActivities()
|
|
}
|
|
|
|
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,
|
|
asset_created: Image,
|
|
asset_updated: Image,
|
|
shot_created: Film,
|
|
shot_updated: Film,
|
|
project_created: FolderPlus,
|
|
project_updated: FolderPlus,
|
|
user_joined_project: UserPlus
|
|
}
|
|
return iconMap[type] || ActivityIcon
|
|
}
|
|
|
|
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',
|
|
asset_created: 'text-cyan-500',
|
|
asset_updated: 'text-cyan-500',
|
|
shot_created: 'text-indigo-500',
|
|
shot_updated: 'text-indigo-500',
|
|
project_created: 'text-emerald-500',
|
|
project_updated: 'text-emerald-500',
|
|
user_joined_project: 'text-purple-500'
|
|
}
|
|
return colorMap[type] || 'text-gray-500'
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
function navigateToTask(taskId: number) {
|
|
router.push(`/tasks?taskId=${taskId}`)
|
|
}
|
|
|
|
function navigateToProject(projectId: number) {
|
|
router.push(`/projects/${projectId}`)
|
|
}
|
|
|
|
import { useAvatarUrl } from '@/composables/useAvatarUrl'
|
|
|
|
const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
|
|
</script>
|