Init Repo

This commit is contained in:
2026-02-28 03:22:04 +08:00
commit de59b57ee7
883 changed files with 156857 additions and 0 deletions
@@ -0,0 +1,481 @@
<template>
<div class="h-full flex flex-col">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading asset details...</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="p-6 text-center">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">Failed to load asset</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p>
<Button @click="loadAssetDetails" variant="outline">
<RefreshCw class="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
<!-- Asset Details -->
<div v-else-if="asset" class="flex-1 overflow-y-auto">
<!-- Header -->
<div class="p-6 border-b">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-xl font-bold truncate" :class="{ 'line-through text-muted-foreground': asset.deleted_at }">{{ asset.name }}</h2>
<Badge :variant="getStatusVariant(asset.status)" class="text-xs flex-shrink-0">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(asset.status)"
></div>
{{ formatStatus(asset.status) }}
</Badge>
<!-- Deletion status indicator for admins -->
<Badge v-if="authStore.isAdmin && asset.deleted_at" variant="destructive" class="text-xs flex-shrink-0">
Deleted {{ formatDeletedDate(asset.deleted_at) }}
</Badge>
</div>
<!-- Close Button -->
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 flex-shrink-0" @click="$emit('close')">
<X class="h-4 w-4" />
</Button>
</div>
</div>
<!-- Tabbed Content -->
<Tabs default-value="infos" class="flex-1 flex flex-col">
<TabsList class="mx-0 mt-0 grid w-full grid-cols-3 rounded-none border-b">
<TabsTrigger value="infos">Infos</TabsTrigger>
<TabsTrigger value="notes">
Notes
<Badge v-if="notes.length > 0" variant="secondary" class="ml-2">
{{ notes.length }}
</Badge>
</TabsTrigger>
<TabsTrigger value="references">
References
<Badge v-if="references.length > 0" variant="secondary" class="ml-2">
{{ references.length }}
</Badge>
</TabsTrigger>
</TabsList>
<!-- Infos Tab -->
<TabsContent value="infos" class="flex-1 overflow-y-auto p-6 space-y-6 m-0">
<!-- Asset Name -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Asset Name</h3>
<p class="text-lg font-medium">{{ asset.name }}</p>
</div>
<!-- Asset Category & Status -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<h3 class="text-sm font-semibold">Category</h3>
<Badge variant="outline" class="text-sm">
{{ formatCategory(asset.category) }}
</Badge>
</div>
<div class="space-y-2">
<h3 class="text-sm font-semibold">Status</h3>
<Badge :variant="getStatusVariant(asset.status)" class="text-sm">
<div
class="w-2 h-2 rounded-full mr-1"
:class="getStatusColor(asset.status)"
></div>
{{ formatStatus(asset.status) }}
</Badge>
</div>
</div>
<!-- Description -->
<div v-if="asset.description" class="space-y-2">
<h3 class="text-sm font-semibold">Description</h3>
<p class="text-sm text-muted-foreground">{{ asset.description }}</p>
</div>
<!-- Progress Overview -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">Task Progress</h3>
<span class="text-sm text-muted-foreground">
{{ completedTasksCount }} / {{ tasks.length }} tasks
</span>
</div>
<!-- Progress Bar -->
<div class="w-full bg-muted rounded-full h-2">
<div
class="bg-primary h-2 rounded-full transition-all duration-300"
:style="{ width: `${progressPercentage}%` }"
></div>
</div>
</div>
<!-- Task Status & Assignees -->
<div class="space-y-3">
<h3 class="text-sm font-semibold">Tasks</h3>
<!-- Loading Tasks -->
<div v-if="isLoading" class="flex items-center justify-center py-4">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span class="text-sm text-muted-foreground">Loading tasks...</span>
</div>
</div>
<!-- No Tasks -->
<div v-else-if="tasks.length === 0" class="text-center py-4 text-sm text-muted-foreground">
No tasks yet
</div>
<!-- Tasks List -->
<div v-else class="space-y-2">
<div
v-for="task in tasks"
:key="task.id"
class="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 cursor-pointer transition-colors"
@click="$emit('select-task', task)"
>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium">{{ formatTaskType(task.task_type) }}</div>
<div class="text-xs text-muted-foreground mt-1">
{{ task.assigned_user_name || 'Unassigned' }}
</div>
</div>
<Badge :variant="getTaskStatusVariant(task.status)" class="text-xs flex-shrink-0">
{{ formatTaskStatus(task.status) }}
</Badge>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="grid grid-cols-2 gap-4 text-xs">
<div>
<Label class="text-muted-foreground">Created</Label>
<p class="mt-1">{{ formatDate(asset.created_at) }}</p>
</div>
<div>
<Label class="text-muted-foreground">Updated</Label>
<p class="mt-1">{{ formatDate(asset.updated_at) }}</p>
</div>
</div>
</TabsContent>
<!-- Notes Tab -->
<TabsContent value="notes" class="flex-1 m-0 overflow-hidden">
<AssetNotes :asset-id="assetId" :notes="notes" @notes-updated="loadNotes" />
</TabsContent>
<!-- References Tab -->
<TabsContent value="references" class="flex-1 m-0 overflow-hidden">
<AssetReferences :asset-id="assetId" :references="references" @references-updated="loadReferences" />
</TabsContent>
</Tabs>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
AlertCircle, RefreshCw, X
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import AssetNotes from './AssetNotes.vue'
import AssetReferences from './AssetReferences.vue'
import { assetService, AssetStatus, type Asset, type TaskStatusInfo } from '@/services/asset'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
interface Task {
id: number
name: string
task_type: string
status: string
assigned_user_name?: string
deadline?: string
}
interface Props {
projectId: number
assetId: number
}
interface Emits {
(e: 'edit', asset: Asset): void
(e: 'delete', asset: Asset): void
(e: 'create-task'): void
(e: 'select-task', task: Task): void
(e: 'create-note'): void
(e: 'upload-reference'): void
(e: 'publish-version'): void
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const userStore = useUserStore()
// Reactive state
const asset = ref<Asset | null>(null)
const notes = ref<any[]>([])
const references = ref<any[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed properties
const tasks = computed(() => {
if (!asset.value?.task_details) return []
// Transform TaskStatusInfo to Task interface expected by the component
return asset.value.task_details.map((taskInfo: TaskStatusInfo) => {
// Find user name from user store if available
const assignedUser = taskInfo.assigned_user_id
? userStore.users.find(user => user.id === taskInfo.assigned_user_id)
: null
return {
id: taskInfo.task_id || 0,
name: formatTaskType(taskInfo.task_type),
task_type: taskInfo.task_type,
status: taskInfo.status,
assigned_user_name: assignedUser
? `${assignedUser.first_name} ${assignedUser.last_name}`.trim()
: undefined
}
})
})
const completedTasksCount = computed(() => {
return tasks.value.filter(task => task.status === 'approved').length
})
const progressPercentage = computed(() => {
if (tasks.value.length === 0) return 0
return Math.round((completedTasksCount.value / tasks.value.length) * 100)
})
const taskStatusCounts = computed(() => {
const counts = {
not_started: 0,
in_progress: 0,
submitted: 0,
approved: 0,
retake: 0
}
tasks.value.forEach(task => {
if (counts.hasOwnProperty(task.status)) {
counts[task.status as keyof typeof counts]++
}
})
return counts
})
// Methods
const loadAssetDetails = async () => {
try {
isLoading.value = true
error.value = null
asset.value = await assetService.getAsset(props.assetId)
// Load users if not already loaded (for user name resolution)
if (userStore.users.length === 0) {
try {
await userStore.fetchAllUsers()
} catch (err) {
console.warn('Failed to load users for name resolution:', err)
}
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load asset details'
console.error('Failed to load asset details:', err)
} finally {
isLoading.value = false
}
}
const loadNotes = async () => {
try {
// Load notes from all tasks associated with this asset
const { taskService } = await import('@/services/task')
const allNotes: any[] = []
for (const task of tasks.value) {
if (task.id) {
const taskNotes = await taskService.getTaskNotes(task.id)
// Add task info to each note for context
const notesWithContext = taskNotes.map(note => ({
...note,
task_name: task.name,
task_type: task.task_type
}))
allNotes.push(...notesWithContext)
}
}
// Sort by date (newest first)
notes.value = allNotes.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
} catch (err) {
console.error('Failed to load notes:', err)
}
}
const loadReferences = async () => {
try {
// Load reference files (attachments) from all tasks
const { taskService } = await import('@/services/task')
const allReferences: any[] = []
for (const task of tasks.value) {
if (task.id) {
const taskAttachments = await taskService.getTaskAttachments(task.id)
// Add task info to each reference for context
const referencesWithContext = taskAttachments.map(attachment => ({
...attachment,
task_name: task.name,
task_type: task.task_type
}))
allReferences.push(...referencesWithContext)
}
}
// Sort by date (newest first)
references.value = allReferences.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
} catch (err) {
console.error('Failed to load references:', err)
}
}
const formatCategory = (category: string) => {
return category.charAt(0).toUpperCase() + category.slice(1)
}
const formatStatus = (status: AssetStatus) => {
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
const formatTaskStatus = (status: string) => {
return status.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
const formatTaskType = (taskType: string) => {
return taskType.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ')
}
const getStatusVariant = (status: AssetStatus) => {
switch (status) {
case AssetStatus.NOT_STARTED:
return 'secondary'
case AssetStatus.IN_PROGRESS:
return 'default'
case AssetStatus.ON_HOLD:
return 'outline'
case AssetStatus.COMPLETED:
return 'default'
case AssetStatus.APPROVED:
return 'default'
default:
return 'secondary'
}
}
const getStatusColor = (status: AssetStatus) => {
switch (status) {
case AssetStatus.NOT_STARTED:
return 'bg-gray-400'
case AssetStatus.IN_PROGRESS:
return 'bg-blue-500'
case AssetStatus.ON_HOLD:
return 'bg-yellow-500'
case AssetStatus.COMPLETED:
return 'bg-green-500'
case AssetStatus.APPROVED:
return 'bg-emerald-600'
default:
return 'bg-gray-400'
}
}
const getTaskStatusVariant = (status: string) => {
switch (status) {
case 'not_started':
return 'secondary'
case 'in_progress':
return 'default'
case 'submitted':
return 'outline'
case 'approved':
return 'default'
case 'retake':
return 'destructive'
default:
return 'secondary'
}
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
const formatDeletedDate = (deletedAt: string) => {
const date = new Date(deletedAt)
const now = new Date()
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60))
if (diffInHours < 24) {
return `${diffInHours}h ago`
} else {
const diffInDays = Math.floor(diffInHours / 24)
return `${diffInDays}d ago`
}
}
// Watchers
watch(() => props.assetId, (newAssetId) => {
if (newAssetId) {
loadAssetDetails()
}
}, { immediate: true })
watch(() => asset.value?.task_details, () => {
if (asset.value?.task_details && asset.value.task_details.length > 0) {
loadNotes()
loadReferences()
}
}, { deep: true })
// Expose methods for parent component
defineExpose({
refresh: loadAssetDetails
})
</script>